From 995d144e3c625e2c094a5dae9d1fe6bf52730426 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Thu, 4 Jun 2026 19:35:27 +0200 Subject: [PATCH] (feature): ship 1.1.0 with full libpod api, typed dtos, streaming attach/progress, and maksit results on net10 --- .cursor/maksit-skills.json | 9 + .cursor/rules/maksit-skills.mdc | 13 + .gitignore | 5 +- AGENTS.md | 9 + CHANGELOG.md | 70 +++ CONTRIBUTING.md | 63 +++ README.md | 155 ++++-- assets/badges/coverage-branches.svg | 21 + assets/badges/coverage-lines.svg | 21 + assets/badges/coverage-methods.svg | 21 + src/PodmanClient.DotNet.nuspec | Bin 1914 -> 0 bytes .../Abstractions/IPodmanBuildClient.cs | 38 ++ .../Abstractions/IPodmanContainersClient.cs | 244 +++++++++ .../Abstractions/IPodmanExecClient.cs | 44 ++ .../Abstractions/IPodmanGenerateClient.cs | 34 ++ .../Abstractions/IPodmanImagesClient.cs | 86 +++ .../Abstractions/IPodmanManifestsClient.cs | 19 + .../Abstractions/IPodmanNetworksClient.cs | 15 + .../Abstractions/IPodmanPodsClient.cs | 24 + .../Abstractions/IPodmanSystemClient.cs | 15 + .../Abstractions/IPodmanVolumesClient.cs | 15 + .../Dtos/Build/BuildProgressLineDto.cs | 11 + src/PodmanClient/Dtos/Build/BuildReportDto.cs | 9 + .../Dtos/Common/ErrorResponseDto.cs | 9 + src/PodmanClient/Dtos/Common/IdResponseDto.cs | 8 + .../Dtos/Common/PruneReportDto.cs | 14 + src/PodmanClient/Dtos/Common/ReportDto.cs | 9 + .../Dtos/Container/ContainerChangesDto.cs | 5 + .../Dtos/Container/ContainerCommitDto.cs | 8 + .../Dtos/Container/ContainerHealthCheckDto.cs | 20 + .../Dtos/Container/ContainerInspectDto.cs | 65 +++ .../Dtos/Container/ContainerListEntryDto.cs | 18 + .../Dtos/Container/ContainerMountDto.cs | 8 + .../Dtos/Container/ContainerStatsDto.cs | 41 ++ .../Dtos/Container/ContainerTopDto.cs | 9 + .../Dtos/Container/ContainerWaitDto.cs | 16 + .../Container/CreateContainerResponseDto.cs | 9 + .../Container/DeleteContainerResponseDto.cs | 9 + .../Dtos/Container/MountedContainerDto.cs | 17 + src/PodmanClient/Dtos/Exec/ExecModelsDto.cs | 17 + .../Dtos/Generate/GenerateModelsDto.cs | 27 + .../Dtos/Image/ImageChangesDto.cs | 5 + src/PodmanClient/Dtos/Image/ImageDeleteDto.cs | 10 + .../Dtos/Image/ImageHistoryEntryDto.cs | 13 + src/PodmanClient/Dtos/Image/ImageImportDto.cs | 9 + .../Dtos/Image/ImageInspectDto.cs | 40 ++ .../Dtos/Image/ImageListEntryDto.cs | 27 + src/PodmanClient/Dtos/Image/ImageLoadDto.cs | 9 + .../Dtos/Image/ImageRemoveResponseDto.cs | 8 + src/PodmanClient/Dtos/Image/ImageTreeDto.cs | 18 + .../Dtos/Image/PullImageResponseDto.cs | 12 + .../Dtos/Manifest/ManifestModelsDto.cs | 36 ++ .../Dtos/Network/NetworkModelsDto.cs | 43 ++ src/PodmanClient/Dtos/Pod/PodModelsDto.cs | 70 +++ src/PodmanClient/Dtos/System/InfoDto.cs | 52 ++ src/PodmanClient/Dtos/System/LibpodPingDto.cs | 8 + .../Dtos/System/LibpodVersionDto.cs | 18 + src/PodmanClient/Dtos/System/SystemDfDto.cs | 18 + .../Dtos/Volume/VolumeModelsDto.cs | 46 ++ .../Extensions/ObjectExtensions.cs | 36 -- .../Extensions/ServiceCollectionExtensions.cs | 29 + .../Extensions/StringExtensions.cs | 35 -- src/PodmanClient/IPodmanClient.cs | 14 + .../IPodmanClientConfiguration.cs | 25 + .../Internal/PodmanHijackConnection.cs | 96 ++++ .../Internal/PodmanHijackStream.cs | 53 ++ .../Internal/PodmanHttpResults.cs | 81 +++ .../Internal/PodmanNdjsonStreams.cs | 64 +++ .../Internal/PodmanOwnedResponseStream.cs | 51 ++ src/PodmanClient/Models/AutoUserNsOptions.cs | 25 +- src/PodmanClient/Models/BindOptions.cs | 26 +- src/PodmanClient/Models/BlockIO.cs | 30 +- src/PodmanClient/Models/CPU.cs | 34 +- .../Container/CreateContainerRequest.cs | 249 ++++----- .../Container/CreateContainerResponse.cs | 20 +- .../Container/DeleteContainerResponse.cs | 21 +- src/PodmanClient/Models/DriverConfig.cs | 20 +- src/PodmanClient/Models/ErrorResponse.cs | 23 +- .../Models/Exec/CreateExecRequest.cs | 39 +- .../Models/Exec/CreateExecResponse.cs | 18 +- .../Models/Exec/InspectExecResponse.cs | 23 +- .../Models/Exec/StartExecRequest.cs | 25 +- src/PodmanClient/Models/HugepageLimit.cs | 21 +- src/PodmanClient/Models/IDMapping.cs | 22 +- src/PodmanClient/Models/IDMappingOptions.cs | 29 +- .../Models/Image/PullImageResponse.cs | 20 +- src/PodmanClient/Models/ImageVolume.cs | 24 +- src/PodmanClient/Models/IntelRdt.cs | 26 +- src/PodmanClient/Models/LinuxDevice.cs | 30 +- src/PodmanClient/Models/LinuxDeviceCgroup.cs | 26 +- src/PodmanClient/Models/LinuxIntelRdt.cs | 26 +- src/PodmanClient/Models/LinuxPersonality.cs | 20 +- src/PodmanClient/Models/LinuxResources.cs | 35 +- src/PodmanClient/Models/LogConfigLibpod.cs | 24 +- src/PodmanClient/Models/Memory.cs | 34 +- src/PodmanClient/Models/Mount.cs | 33 +- src/PodmanClient/Models/NamedVolume.cs | 26 +- src/PodmanClient/Models/Namespace.cs | 20 +- src/PodmanClient/Models/Network.cs | 17 +- .../Models/Network/NetworkModels.cs | 40 ++ src/PodmanClient/Models/NetworkPriority.cs | 20 +- src/PodmanClient/Models/NetworkSettings.cs | 24 +- src/PodmanClient/Models/OverlayVolume.cs | 22 +- src/PodmanClient/Models/POSIXRlimit.cs | 22 +- src/PodmanClient/Models/Pids.cs | 18 +- src/PodmanClient/Models/Pod/PodModels.cs | 24 + src/PodmanClient/Models/PortMapping.cs | 26 +- src/PodmanClient/Models/ProgressDetail.cs | 21 +- src/PodmanClient/Models/RdmaResource.cs | 20 +- .../Models/Schema2HealthConfig.cs | 28 +- src/PodmanClient/Models/SecretProp.cs | 20 +- .../Models/StartupHealthConfig.cs | 30 +- src/PodmanClient/Models/ThrottleDevice.cs | 22 +- src/PodmanClient/Models/TmpfsOptions.cs | 22 +- .../Models/Volume/VolumeModels.cs | 12 + src/PodmanClient/Models/VolumeOptions.cs | 24 +- src/PodmanClient/Models/WeightDevice.cs | 24 +- src/PodmanClient/PodmanClient.Build.cs | 53 ++ src/PodmanClient/PodmanClient.Container.cs | 367 +++++++++++++ .../PodmanClient.Containers.Api.cs | 282 ++++++++++ src/PodmanClient/PodmanClient.Exec.cs | 88 ++++ src/PodmanClient/PodmanClient.Generate.cs | 75 +++ src/PodmanClient/PodmanClient.Http.cs | 276 ++++++++++ src/PodmanClient/PodmanClient.Image.cs | 64 +++ src/PodmanClient/PodmanClient.Images.Api.cs | 191 +++++++ src/PodmanClient/PodmanClient.Manifests.cs | 53 ++ src/PodmanClient/PodmanClient.Networks.cs | 49 ++ src/PodmanClient/PodmanClient.Pods.cs | 73 +++ src/PodmanClient/PodmanClient.Streaming.cs | 181 +++++++ src/PodmanClient/PodmanClient.System.cs | 23 + src/PodmanClient/PodmanClient.Volumes.cs | 34 ++ src/PodmanClient/PodmanClient.cs | 110 ++-- src/PodmanClient/PodmanClientContainer.cs | 496 ------------------ src/PodmanClient/PodmanClientDotNet.csproj | 55 +- src/PodmanClient/PodmanClientExec.cs | 168 ------ src/PodmanClient/PodmanClientImage.cs | 97 ---- .../Streaming/IPodmanAttachSession.cs | 27 + .../Streaming/IPodmanProgressSession.cs | 8 + .../Streaming/PodmanAttachSession.cs | 83 +++ .../Streaming/PodmanMultiplexedProtocol.cs | 42 ++ .../Streaming/PodmanProgressSession.cs | 42 ++ .../Streaming/PodmanStreamFrame.cs | 10 + .../Streaming/PodmanStreamType.cs | 11 + src/PodmanClientDotNet.Tests/Archives/Tar.cs | 77 ++- .../PodmanClientContainersTests.cs | 245 ++++----- .../PodmanClientDotNet.Tests.csproj | 12 +- .../PodmanClientExecTests.cs | 306 +++++------ .../PodmanClientImagesTests.cs | 71 +-- .../PodmanClientStreamingIntegrationTests.cs | 128 +++++ .../PodmanClientTestFixture.cs | 42 ++ .../PodmanAttachSessionCloseWriteTests.cs | 13 + .../Streaming/PodmanAttachSessionTests.cs | 51 ++ .../Streaming/PodmanHijackConnectionTests.cs | 49 ++ .../Streaming/PodmanHijackMockServer.cs | 44 ++ .../PodmanMultiplexedProtocolTests.cs | 38 ++ .../Streaming/PodmanNdjsonStreamsTests.cs | 56 ++ .../Streaming/PodmanProgressSessionTests.cs | 23 + src/PodmanClientDotNet.sln | 31 -- src/PodmanClientDotNet.slnx | 4 + src/Release-NuGetPackage.bat | 7 - src/Release-NuGetPackage.ps1 | 46 -- src/Release-NuGetPackage.sh | 49 -- utils/Force-AmendTaggedCommit.bat | 3 + utils/Invoke-ReleasePackage.bat | 3 + utils/Invoke-TestEngine.bat | 3 + utils/Update-RepoUtils.bat | 3 + .../engines/release/Invoke-ReleasePackage.ps1 | 80 +++ utils/engines/release/custom/.gitkeep | 1 + utils/engines/release/scriptSettings.json | 88 ++++ utils/engines/test/Invoke-TestEngine.ps1 | 50 ++ utils/engines/test/custom/.gitkeep | 0 utils/engines/test/scriptSettings.json | 56 ++ utils/modules/ChangelogSupport.psm1 | 56 ++ utils/modules/Engine/EngineContext.psm1 | 225 ++++++++ utils/modules/Engine/Import-EngineModules.ps1 | 35 ++ utils/modules/Engine/PluginSupport.psm1 | 386 ++++++++++++++ utils/modules/Engine/ReleaseSupport.psm1 | 151 ++++++ utils/modules/Engine/TestSupport.psm1 | 38 ++ utils/modules/GitTools.psm1 | 268 ++++++++++ utils/modules/Logging.psm1 | 70 +++ utils/modules/ScriptConfig.psm1 | 35 ++ utils/modules/TestRunner.psm1 | 431 +++++++++++++++ .../DotNet/DotNetCleanupArtifacts.psm1 | 122 +++++ utils/plugins/DotNet/DotNetCreateArchive.psm1 | 94 ++++ utils/plugins/DotNet/DotNetDockerPush.psm1 | 245 +++++++++ utils/plugins/DotNet/DotNetHelmPush.psm1 | 181 +++++++ utils/plugins/DotNet/DotNetNuGet.psm1 | 71 +++ utils/plugins/DotNet/DotNetPack.psm1 | 128 +++++ utils/plugins/DotNet/DotNetPublish.psm1 | 72 +++ .../plugins/DotNet/DotNetReleaseVersion.psm1 | 41 ++ utils/plugins/DotNet/DotNetTest.psm1 | 159 ++++++ utils/plugins/Npm/NpmBuild.psm1 | 89 ++++ utils/plugins/Npm/NpmJestTest.psm1 | 83 +++ utils/plugins/Npm/NpmPublish.psm1 | 118 +++++ utils/plugins/Npm/NpmReleaseVersion.psm1 | 99 ++++ utils/plugins/Platform/CoverageBadges.psm1 | 178 +++++++ utils/plugins/Platform/GitHub.psm1 | 244 +++++++++ utils/plugins/Platform/QualityGate.psm1 | 185 +++++++ .../plugins/Platform/ReleasePublishGuard.psm1 | 167 ++++++ utils/tools/Enable-ModelsNullable.ps1 | 34 ++ .../Force-AmendTaggedCommit.ps1 | 248 +++++++++ .../scriptSettings.json | 18 + utils/tools/Polish-PodmanClientSources.ps1 | 117 +++++ .../Update-RepoUtils/Update-RepoUtils.ps1 | 358 +++++++++++++ .../Update-RepoUtils/scriptSettings.json | 16 + 205 files changed, 10679 insertions(+), 2201 deletions(-) create mode 100644 .cursor/maksit-skills.json create mode 100644 .cursor/rules/maksit-skills.mdc create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 assets/badges/coverage-branches.svg create mode 100644 assets/badges/coverage-lines.svg create mode 100644 assets/badges/coverage-methods.svg delete mode 100644 src/PodmanClient.DotNet.nuspec create mode 100644 src/PodmanClient/Abstractions/IPodmanBuildClient.cs create mode 100644 src/PodmanClient/Abstractions/IPodmanContainersClient.cs create mode 100644 src/PodmanClient/Abstractions/IPodmanExecClient.cs create mode 100644 src/PodmanClient/Abstractions/IPodmanGenerateClient.cs create mode 100644 src/PodmanClient/Abstractions/IPodmanImagesClient.cs create mode 100644 src/PodmanClient/Abstractions/IPodmanManifestsClient.cs create mode 100644 src/PodmanClient/Abstractions/IPodmanNetworksClient.cs create mode 100644 src/PodmanClient/Abstractions/IPodmanPodsClient.cs create mode 100644 src/PodmanClient/Abstractions/IPodmanSystemClient.cs create mode 100644 src/PodmanClient/Abstractions/IPodmanVolumesClient.cs create mode 100644 src/PodmanClient/Dtos/Build/BuildProgressLineDto.cs create mode 100644 src/PodmanClient/Dtos/Build/BuildReportDto.cs create mode 100644 src/PodmanClient/Dtos/Common/ErrorResponseDto.cs create mode 100644 src/PodmanClient/Dtos/Common/IdResponseDto.cs create mode 100644 src/PodmanClient/Dtos/Common/PruneReportDto.cs create mode 100644 src/PodmanClient/Dtos/Common/ReportDto.cs create mode 100644 src/PodmanClient/Dtos/Container/ContainerChangesDto.cs create mode 100644 src/PodmanClient/Dtos/Container/ContainerCommitDto.cs create mode 100644 src/PodmanClient/Dtos/Container/ContainerHealthCheckDto.cs create mode 100644 src/PodmanClient/Dtos/Container/ContainerInspectDto.cs create mode 100644 src/PodmanClient/Dtos/Container/ContainerListEntryDto.cs create mode 100644 src/PodmanClient/Dtos/Container/ContainerMountDto.cs create mode 100644 src/PodmanClient/Dtos/Container/ContainerStatsDto.cs create mode 100644 src/PodmanClient/Dtos/Container/ContainerTopDto.cs create mode 100644 src/PodmanClient/Dtos/Container/ContainerWaitDto.cs create mode 100644 src/PodmanClient/Dtos/Container/CreateContainerResponseDto.cs create mode 100644 src/PodmanClient/Dtos/Container/DeleteContainerResponseDto.cs create mode 100644 src/PodmanClient/Dtos/Container/MountedContainerDto.cs create mode 100644 src/PodmanClient/Dtos/Exec/ExecModelsDto.cs create mode 100644 src/PodmanClient/Dtos/Generate/GenerateModelsDto.cs create mode 100644 src/PodmanClient/Dtos/Image/ImageChangesDto.cs create mode 100644 src/PodmanClient/Dtos/Image/ImageDeleteDto.cs create mode 100644 src/PodmanClient/Dtos/Image/ImageHistoryEntryDto.cs create mode 100644 src/PodmanClient/Dtos/Image/ImageImportDto.cs create mode 100644 src/PodmanClient/Dtos/Image/ImageInspectDto.cs create mode 100644 src/PodmanClient/Dtos/Image/ImageListEntryDto.cs create mode 100644 src/PodmanClient/Dtos/Image/ImageLoadDto.cs create mode 100644 src/PodmanClient/Dtos/Image/ImageRemoveResponseDto.cs create mode 100644 src/PodmanClient/Dtos/Image/ImageTreeDto.cs create mode 100644 src/PodmanClient/Dtos/Image/PullImageResponseDto.cs create mode 100644 src/PodmanClient/Dtos/Manifest/ManifestModelsDto.cs create mode 100644 src/PodmanClient/Dtos/Network/NetworkModelsDto.cs create mode 100644 src/PodmanClient/Dtos/Pod/PodModelsDto.cs create mode 100644 src/PodmanClient/Dtos/System/InfoDto.cs create mode 100644 src/PodmanClient/Dtos/System/LibpodPingDto.cs create mode 100644 src/PodmanClient/Dtos/System/LibpodVersionDto.cs create mode 100644 src/PodmanClient/Dtos/System/SystemDfDto.cs create mode 100644 src/PodmanClient/Dtos/Volume/VolumeModelsDto.cs delete mode 100644 src/PodmanClient/Extensions/ObjectExtensions.cs create mode 100644 src/PodmanClient/Extensions/ServiceCollectionExtensions.cs delete mode 100644 src/PodmanClient/Extensions/StringExtensions.cs create mode 100644 src/PodmanClient/IPodmanClient.cs create mode 100644 src/PodmanClient/IPodmanClientConfiguration.cs create mode 100644 src/PodmanClient/Internal/PodmanHijackConnection.cs create mode 100644 src/PodmanClient/Internal/PodmanHijackStream.cs create mode 100644 src/PodmanClient/Internal/PodmanHttpResults.cs create mode 100644 src/PodmanClient/Internal/PodmanNdjsonStreams.cs create mode 100644 src/PodmanClient/Internal/PodmanOwnedResponseStream.cs create mode 100644 src/PodmanClient/Models/Network/NetworkModels.cs create mode 100644 src/PodmanClient/Models/Pod/PodModels.cs create mode 100644 src/PodmanClient/Models/Volume/VolumeModels.cs create mode 100644 src/PodmanClient/PodmanClient.Build.cs create mode 100644 src/PodmanClient/PodmanClient.Container.cs create mode 100644 src/PodmanClient/PodmanClient.Containers.Api.cs create mode 100644 src/PodmanClient/PodmanClient.Exec.cs create mode 100644 src/PodmanClient/PodmanClient.Generate.cs create mode 100644 src/PodmanClient/PodmanClient.Http.cs create mode 100644 src/PodmanClient/PodmanClient.Image.cs create mode 100644 src/PodmanClient/PodmanClient.Images.Api.cs create mode 100644 src/PodmanClient/PodmanClient.Manifests.cs create mode 100644 src/PodmanClient/PodmanClient.Networks.cs create mode 100644 src/PodmanClient/PodmanClient.Pods.cs create mode 100644 src/PodmanClient/PodmanClient.Streaming.cs create mode 100644 src/PodmanClient/PodmanClient.System.cs create mode 100644 src/PodmanClient/PodmanClient.Volumes.cs delete mode 100644 src/PodmanClient/PodmanClientContainer.cs delete mode 100644 src/PodmanClient/PodmanClientExec.cs delete mode 100644 src/PodmanClient/PodmanClientImage.cs create mode 100644 src/PodmanClient/Streaming/IPodmanAttachSession.cs create mode 100644 src/PodmanClient/Streaming/IPodmanProgressSession.cs create mode 100644 src/PodmanClient/Streaming/PodmanAttachSession.cs create mode 100644 src/PodmanClient/Streaming/PodmanMultiplexedProtocol.cs create mode 100644 src/PodmanClient/Streaming/PodmanProgressSession.cs create mode 100644 src/PodmanClient/Streaming/PodmanStreamFrame.cs create mode 100644 src/PodmanClient/Streaming/PodmanStreamType.cs create mode 100644 src/PodmanClientDotNet.Tests/PodmanClientStreamingIntegrationTests.cs create mode 100644 src/PodmanClientDotNet.Tests/PodmanClientTestFixture.cs create mode 100644 src/PodmanClientDotNet.Tests/Streaming/PodmanAttachSessionCloseWriteTests.cs create mode 100644 src/PodmanClientDotNet.Tests/Streaming/PodmanAttachSessionTests.cs create mode 100644 src/PodmanClientDotNet.Tests/Streaming/PodmanHijackConnectionTests.cs create mode 100644 src/PodmanClientDotNet.Tests/Streaming/PodmanHijackMockServer.cs create mode 100644 src/PodmanClientDotNet.Tests/Streaming/PodmanMultiplexedProtocolTests.cs create mode 100644 src/PodmanClientDotNet.Tests/Streaming/PodmanNdjsonStreamsTests.cs create mode 100644 src/PodmanClientDotNet.Tests/Streaming/PodmanProgressSessionTests.cs delete mode 100644 src/PodmanClientDotNet.sln create mode 100644 src/PodmanClientDotNet.slnx delete mode 100644 src/Release-NuGetPackage.bat delete mode 100644 src/Release-NuGetPackage.ps1 delete mode 100644 src/Release-NuGetPackage.sh create mode 100644 utils/Force-AmendTaggedCommit.bat create mode 100644 utils/Invoke-ReleasePackage.bat create mode 100644 utils/Invoke-TestEngine.bat create mode 100644 utils/Update-RepoUtils.bat create mode 100644 utils/engines/release/Invoke-ReleasePackage.ps1 create mode 100644 utils/engines/release/custom/.gitkeep create mode 100644 utils/engines/release/scriptSettings.json create mode 100644 utils/engines/test/Invoke-TestEngine.ps1 create mode 100644 utils/engines/test/custom/.gitkeep create mode 100644 utils/engines/test/scriptSettings.json create mode 100644 utils/modules/ChangelogSupport.psm1 create mode 100644 utils/modules/Engine/EngineContext.psm1 create mode 100644 utils/modules/Engine/Import-EngineModules.ps1 create mode 100644 utils/modules/Engine/PluginSupport.psm1 create mode 100644 utils/modules/Engine/ReleaseSupport.psm1 create mode 100644 utils/modules/Engine/TestSupport.psm1 create mode 100644 utils/modules/GitTools.psm1 create mode 100644 utils/modules/Logging.psm1 create mode 100644 utils/modules/ScriptConfig.psm1 create mode 100644 utils/modules/TestRunner.psm1 create mode 100644 utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 create mode 100644 utils/plugins/DotNet/DotNetCreateArchive.psm1 create mode 100644 utils/plugins/DotNet/DotNetDockerPush.psm1 create mode 100644 utils/plugins/DotNet/DotNetHelmPush.psm1 create mode 100644 utils/plugins/DotNet/DotNetNuGet.psm1 create mode 100644 utils/plugins/DotNet/DotNetPack.psm1 create mode 100644 utils/plugins/DotNet/DotNetPublish.psm1 create mode 100644 utils/plugins/DotNet/DotNetReleaseVersion.psm1 create mode 100644 utils/plugins/DotNet/DotNetTest.psm1 create mode 100644 utils/plugins/Npm/NpmBuild.psm1 create mode 100644 utils/plugins/Npm/NpmJestTest.psm1 create mode 100644 utils/plugins/Npm/NpmPublish.psm1 create mode 100644 utils/plugins/Npm/NpmReleaseVersion.psm1 create mode 100644 utils/plugins/Platform/CoverageBadges.psm1 create mode 100644 utils/plugins/Platform/GitHub.psm1 create mode 100644 utils/plugins/Platform/QualityGate.psm1 create mode 100644 utils/plugins/Platform/ReleasePublishGuard.psm1 create mode 100644 utils/tools/Enable-ModelsNullable.ps1 create mode 100644 utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 create mode 100644 utils/tools/Force-AmendTaggedCommit/scriptSettings.json create mode 100644 utils/tools/Polish-PodmanClientSources.ps1 create mode 100644 utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 create mode 100644 utils/tools/Update-RepoUtils/scriptSettings.json diff --git a/.cursor/maksit-skills.json b/.cursor/maksit-skills.json new file mode 100644 index 0000000..5ff1424 --- /dev/null +++ b/.cursor/maksit-skills.json @@ -0,0 +1,9 @@ +{ + "$schema": "homelab-maksit-skills-manifest-v1", + "skillsRootRelative": "E:\\Users\\maksym\\source\\repos\\private\\homelab\\ai\\skills", + "skills": [ + "common/csharp", + "common/maksit-repo-maintenance", + "local-ollama" + ] +} diff --git a/.cursor/rules/maksit-skills.mdc b/.cursor/rules/maksit-skills.mdc new file mode 100644 index 0000000..c543164 --- /dev/null +++ b/.cursor/rules/maksit-skills.mdc @@ -0,0 +1,13 @@ +--- +description: Load MaksIT agent skills from homelab (deterministic) +globs: "**/*.{cs,csproj,slnx,md}" +alwaysApply: true +--- + +# MaksIT skills (podman-client-dotnet) + +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` + +Manifest: `.cursor/maksit-skills.json`. diff --git a/.gitignore b/.gitignore index 5f09bf8..f1994ed 100644 --- a/.gitignore +++ b/.gitignore @@ -261,6 +261,5 @@ paket-files/ __pycache__/ *.pyc - -**/*docker-compose/LetsEncryptServer/acme -**/*docker-compose/LetsEncryptServer/cache \ No newline at end of file +#Custom +![Uu]tils/** diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4347939 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Agent instructions (PodmanClient.DotNet) + +| Skill | Path | +|-------|--------| +| 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) | + +Manifest: `.cursor/maksit-skills.json`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..08ae60b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +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). + +## [1.1.0] - 2026-06-04 + +### Added + +- Full **Libpod API** coverage (~86 endpoints) via domain interfaces: `IPodmanSystemClient`, `IPodmanContainersClient`, `IPodmanImagesClient`, `IPodmanVolumesClient`, `IPodmanNetworksClient`, `IPodmanPodsClient`, `IPodmanExecClient`, `IPodmanBuildClient`, `IPodmanManifestsClient`, `IPodmanGenerateClient` (composed by `IPodmanClient`). +- Typed API responses under `Dtos/` (`*Dto` suffix); request/spec payloads remain in `Models/`. +- **Streaming APIs:** `AttachContainerSessionAsync`, `StartExecSessionAsync` (`IPodmanAttachSession`), `PullImageWithProgressAsync`, `BuildImageWithProgressAsync` (`IPodmanProgressSession`), plus hijack connection and multiplex protocol internals. +- Shared HTTP helpers in `PodmanClient.Http.cs` and NDJSON stream handling in `PodmanNdjsonStreams`. +- `IPodmanClientConfiguration`, `AddPodmanClient` (`IHttpClientFactory` / `AddHttpClient`); host apps supply their own configuration implementation. +- Unit tests for streaming, NDJSON, and hijack mock server; integration tests tagged `Category=Integration` (skip without `PODMAN_TEST_URL`). +- `CHANGELOG.md`, `CONTRIBUTING.md`, coverage badge assets, and `utils/` (RepoUtils test/release engines). + +### Changed + +- Target framework upgraded to **.NET 10** (`net10.0`). +- API methods return **MaksIT.Results** `Result` / `Result` instead of throwing on Podman HTTP errors. +- Added **MaksIT.Core** and **MaksIT.Results** dependencies; removed local `Extensions` (`ToJson` / `ToObject`) in favor of `MaksIT.Core.Extensions`. +- `PodmanClient` split into partials (`PodmanClient.Http.cs`, `PodmanClient.Containers.Api.cs`, etc.); solution file migrated to `PodmanClientDotNet.slnx`. +- Package metadata, Source Link, symbol packages, and documentation generation aligned with [maksit-core](https://github.com/MAKS-IT-COM/maksit-core) standards. +- Registry auth (`X-Registry-Auth`) applied per HTTP request instead of mutating shared `HttpClient.DefaultRequestHeaders`. +- Replaced legacy `src/Release-NuGetPackage.*` scripts and `.nuspec` with SDK-style pack + `utils/` release tooling. + +### Fixed + +- Pull, push, and build endpoints consume NDJSON progress streams correctly; `BuildImageAsync` no longer deserializes a multi-line build stream as a single JSON object. +- Attach hijack requests include the `tty` query parameter. +- Manual `PodmanClient` constructor preserves caller-configured `HttpClient.Timeout` (no longer truncated via integer minutes cast). + +### Removed + +- Concrete `PodmanClientConfiguration` type from the library package. +- Monolithic `PodmanClientContainer.cs`, `PodmanClientExec.cs`, and `PodmanClientImage.cs` (superseded by partials). + +### Breaking + +- Method return types changed from `Task` / `Task` to `Result` / `Result`. +- Response types moved to `Dtos/`; update usings from `Models.*` response classes. +- Removed `PodmanClientConfiguration`; bind `IPodmanClientConfiguration` with a host-owned options class. +- Prefer `IPodmanClient` and `AddPodmanClient` for DI; manual `PodmanClient` constructors remain for tests and simple hosts. + +## [1.0.4] - 2024-08-18 + +### Added + +- Integration tests for container lifecycle, exec, and image pull/tag. + +### Fixed + +- Empty-string JSON parse issue in HTTP response handling. + +### Changed + +- Package readme and repository documentation updates. + +## [1.0.2] - 2024-08-17 + +### Added + +- Initial **PodmanClient.DotNet** library on **.NET 8** (`net8.0`). +- Container operations: create, start, stop, delete, archive copy. +- Exec operations: create, start, inspect. +- Image operations: pull, tag. +- NuGet packaging (`.nuspec`, `Release-NuGetPackage` scripts) and README. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..42eec7d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,63 @@ +# Contributing to PodmanClient.DotNet + +Thank you for contributing. This repo follows MaksIT conventions (see `AGENTS.md` and homelab `common/csharp` / `common/maksit-repo-maintenance` skills). + +## Development setup + +### Prerequisites + +- .NET 10 SDK +- Git +- Optional: reachable Podman API for integration tests (`PODMAN_TEST_URL` or `PODMAN_INTEGRATION_URL`) + +### Build + +```bash +dotnet build src/PodmanClientDotNet.slnx +``` + +### Tests + +**RepoUtils test engine** (coverage + badges): + +```bash +utils/Invoke-TestEngine.bat +``` + +**Direct:** + +```bash +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`). + +## Commit message format + +```text +(type): description +``` + +Types: `(feature):`, `(bugfix):`, `(refactor):`, `(perf):`, `(test):`, `(docs):`, `(build):`, `(ci):`, `(style):`, `(revert):`, `(chore):`. + +- Lowercase description; no trailing period. + +## Code style + +- **.NET 10**, nullable reference types, implicit usings. +- **Root namespace**: `MaksIT.$(MSBuildProjectName)` in `PodmanClientDotNet.csproj`; omit `namespace` when it matches the root (client partials, abstractions). +- **MaksIT.Results** for API outcomes; **MaksIT.Core.Extensions** for JSON (`ToJson` / `ToObject`). +- 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. +2. Update **README.md** / **CHANGELOG.md** when behavior or public API changes. +3. Refresh **`assets/badges/*.svg`** when coverage badges change. +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. diff --git a/README.md b/README.md index cda95b3..c23e7e5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # PodmanClient.DotNet +![Line Coverage](assets/badges/coverage-lines.svg) ![Branch Coverage](assets/badges/coverage-branches.svg) ![Method Coverage](assets/badges/coverage-methods.svg) + ## Description `PodmanClient.DotNet` is a .NET library designed to provide seamless interaction with the Podman API, allowing developers to manage and control containers directly from their .NET applications. This client library wraps the Podman API endpoints, offering a .NET-friendly interface to perform common container operations such as creating, starting, stopping, deleting containers, and more. @@ -10,13 +12,12 @@ The primary goal of `PodmanClient.DotNet` is to simplify the integration of Podm ## Key Features -- **Container Management:** Create, start, stop, and delete containers with straightforward methods. -- **Image Operations:** Pull, tag, and manage images using the Podman API. -- **Exec Support:** Execute commands inside running containers, with support for attaching input/output streams. -- **Volume and Network Management:** Manage container volumes and networks as needed. -- **Streamlined Error Handling:** Provides detailed error handling, with informative responses based on HTTP status codes. -- **Customizable HTTP Client:** Easily configure and inject your own `HttpClient` instance for extended control and customization. +- **Full Libpod API surface:** System, containers, images, volumes, networks, pods, exec, build, manifests, and generate/play/kube (see `IPodmanClient` and domain interfaces under `Abstractions/`). +- **Domain-oriented API:** `IPodmanContainersClient`, `IPodmanImagesClient`, `IPodmanVolumesClient`, and related interfaces; `IPodmanClient` composes them all. +- **Structured results:** API methods return `MaksIT.Results.Result` / `Result` instead of throwing on Podman HTTP errors. +- **Dependency injection:** Register with `AddHttpClient` via `AddPodmanClient` and inject `IPodmanClient`. - **Logging Support:** Integrated logging support via `Microsoft.Extensions.Logging` for better observability. +- **Streaming:** Full-duplex attach/exec sessions (`IPodmanAttachSession`) and NDJSON progress for pull/build (`IPodmanProgressSession`). ## Installation @@ -28,20 +29,64 @@ dotnet add package PodmanClient.DotNet ## Usage Examples -### Initialize the PodmanClient +### Dependency injection (recommended) + +`appsettings.json`: + +```json +{ + "PodmanClient": { + "ServerUrl": "http://localhost:8080", + "ApiVersion": "v1.41", + "TimeoutMinutes": 5 + } +} +``` + +```csharp +using MaksIT.PodmanClientDotNet; +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 int TimeoutMinutes { get; set; } = 60; +} + +var podmanConfiguration = builder.Configuration + .GetSection(IPodmanClientConfiguration.SectionName) + .Get() + ?? throw new InvalidOperationException("PodmanClient configuration is missing."); + +builder.Services.AddPodmanClient(podmanConfiguration); + +// Inject IPodmanClient where needed +public sealed class ContainerService(IPodmanClient podman) { + public async Task RunAsync() { + var create = await podman.CreateContainerAsync("my-container", "alpine:latest"); + if (!create.IsSuccess) + return create.ToResult(); + + return await podman.StartContainerAsync(create.Value!.Id); + } +} +``` + +### Manual construction ```csharp using Microsoft.Extensions.Logging; -using MaksIT.PodmanClient.DotNet; +using MaksIT.PodmanClientDotNet; var logger = LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(); -var podmanClient = new PodmanClient(logger, "http://localhost:8080", 5); +IPodmanClient podmanClient = new PodmanClient(logger, "http://localhost:8080", 5); ``` ### Create and Start a Container ```csharp -var createResponse = await podmanClient.CreateContainerAsync( +var createResult = await podmanClient.CreateContainerAsync( name: "my-container", image: "alpine:latest", command: new List { "/bin/sh" }, @@ -49,20 +94,24 @@ var createResponse = await podmanClient.CreateContainerAsync( remove: true ); -await podmanClient.StartContainerAsync(createResponse.Id); +if (!createResult.IsSuccess) + return createResult.ToActionResult(); // in ASP.NET Core controllers + +await podmanClient.StartContainerAsync(createResult.Value!.Id); ``` ### Execute a Command in a Container ```csharp -var execResponse = await podmanClient.CreateExecAsync(createResponse.Id, new[] { "echo", "Hello, World!" }); -await podmanClient.StartExecAsync(execResponse.Id); +var execResult = await podmanClient.CreateExecAsync(createResult.Value!.Id, new[] { "echo", "Hello, World!" }); +if (execResult.IsSuccess) + await podmanClient.StartExecAsync(execResult.Value!.Id); ``` ### Pull an Image ```csharp -await podmanClient.PullImageAsync("alpine:latest"); +var pullResult = await podmanClient.PullImageAsync("alpine:latest"); ``` ### Tag an Image @@ -71,28 +120,60 @@ await podmanClient.PullImageAsync("alpine:latest"); await podmanClient.TagImageAsync("alpine:latest", "myrepo", "mytag"); ``` +### Full-duplex attach (container or exec) + +Use session APIs for multiplexed stdin/stdout/stderr (or raw TTY) instead of a one-shot `Stream`: + +```csharp +var attach = await podmanClient.AttachContainerSessionAsync( + containerId, stream: true, stdout: true, stderr: true, stdin: true, tty: false); +if (!attach.IsSuccess) return; + +await using var session = attach.Value!; +while (await session.ReadFrameAsync() is { } frame) + Console.Write(frame.StreamType + ": " + Encoding.UTF8.GetString(frame.Data)); + +await session.WriteStdinAsync("input"u8.ToArray()); +await session.CloseWriteAsync(); +``` + +Exec hijack: `CreateExecAsync` → `StartExecSessionAsync(execId, tty: false)`. + +Pull/build progress (NDJSON): `PullImageWithProgressAsync` / `BuildImageWithProgressAsync` return `IPodmanProgressSession`; enumerate with `await foreach (var line in session.ReadProgressAsync())`. + ## Available Methods -### `PodmanClient` +### `IPodmanClient` -- **Container Management** - - `Task CreateContainerAsync(...)`: Creates a new container. - - `Task StartContainerAsync(string containerId, string detachKeys = "ctrl-p,ctrl-q")`: Starts an existing container. - - `Task StopContainerAsync(string containerId, int timeout = 10, bool ignoreAlreadyStopped = false)`: Stops a running container. - - `Task DeleteContainerAsync(string containerId, bool depend = false, bool ignore = false, int timeout = 10)`: Deletes a container. - - `Task ForceDeleteContainerAsync(string containerId, bool deleteVolumes = false, int timeout = 10)`: Forcefully deletes a container, optionally removing associated volumes. +Register with `AddPodmanClient` or construct `PodmanClient` manually. Methods return `Result` / `Result` from **MaksIT.Results**. -- **Exec Management** - - `Task CreateExecAsync(...)`: Creates an exec instance in a running container. - - `Task StartExecAsync(string execId, bool detach = false, bool tty = false, int? height = null, int? width = null)`: Starts an exec instance. - - `Task InspectExecAsync(string execId)`: Inspects an exec instance to retrieve its details. +| Interface | Coverage | +|-----------|----------| +| `IPodmanSystemClient` | ping, version, info, events, disk usage, system prune | +| `IPodmanContainersClient` | create, list, inspect, lifecycle, logs, stats, archive, attach, commit, checkpoint, prune, … | +| `IPodmanImagesClient` | pull, push, list, inspect, tag, untag, search, load, import, export, prune, … | +| `IPodmanVolumesClient` | create, list, inspect, delete, prune | +| `IPodmanNetworksClient` | create, list, inspect, delete, connect, disconnect | +| `IPodmanPodsClient` | create, list, inspect, lifecycle, stats, prune, … | +| `IPodmanExecClient` | create, start, resize, inspect | +| `IPodmanBuildClient` | `BuildImageAsync` | +| `IPodmanManifestsClient` | create, inspect, add, push, delete | +| `IPodmanGenerateClient` | systemd unit, kube yaml, play kube | -- **Image Operations** - - `Task PullImageAsync(...)`: Pulls an image from a container registry. - - `Task TagImageAsync(string image, string repo, string tag)`: Tags an existing image with a new repository and tag. +API responses are typed under `Dtos/` (for example `ContainerInspectDto`, `ImageInspectDto`, `InfoDto`). Request/spec payloads remain in `Models/`. -- **File Operations** - - `Task ExtractArchiveToContainerAsync(string containerId, Stream tarStream, string path, bool pause = true)`: Extracts files from a tar stream into a container. +## Tests + +Unit tests cover multiplex framing, attach sessions, NDJSON progress, and a local hijack mock server. Integration tests require a reachable Podman API: + +```shell +$env:PODMAN_TEST_URL = "http://localhost:8080" +dotnet test src/PodmanClientDotNet.Tests/PodmanClientDotNet.Tests.csproj +``` + +Without `PODMAN_TEST_URL` (or `PODMAN_INTEGRATION_URL`), integration tests are skipped automatically. Filter them in CI with `--filter "Category!=Integration"`. + +**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. ## Documentation (TODO: Agile) @@ -100,18 +181,11 @@ For detailed documentation on each method, including parameter descriptions and ## Contribution -Contributions to this project are welcome! Please fork the repository and submit a pull request with your changes. If you encounter any issues or have feature requests, feel free to open an issue on GitHub. - -## Contact - -If you have any questions or need further assistance, feel free to reach out: - -- **Email**: [maksym.sadovnychyy@gmail.com](mailto:maksym.sadovnychyy@gmail.com) -- **Reddit**: [PodmanClient.DotNet: A .NET Library for Streamlined Podman API Integration](https://www.reddit.com/r/MaksIT/comments/1evel9z/podmanclientdotnet_a_net_library_for_streamlined/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button) +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for build/test commands, commit format, and PR expectations. Open an issue on GitHub for bugs or feature requests. ## License -This project is licensed under the MIT License. See the full license text below. +This project is licensed under the MIT License. See [LICENSE.md](LICENSE.md). --- @@ -143,4 +217,5 @@ SOFTWARE. ## Contact -For any questions or inquiries, please reach out via GitHub or [email](mailto:maksym.sadovnychyy@gmail.com). \ No newline at end of file +- **Email**: [maksym.sadovnychyy@gmail.com](mailto:maksym.sadovnychyy@gmail.com) +- **Reddit**: [PodmanClient.DotNet: A .NET Library for Streamlined Podman API Integration](https://www.reddit.com/r/MaksIT/comments/1evel9z/podmanclientdotnet_a_net_library_for_streamlined/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button) \ No newline at end of file diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg new file mode 100644 index 0000000..5fe1963 --- /dev/null +++ b/assets/badges/coverage-branches.svg @@ -0,0 +1,21 @@ + + Branch Coverage: 20.9% + + + + + + + + + + + + + + + Branch Coverage + + 20.9% + + diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg new file mode 100644 index 0000000..0088313 --- /dev/null +++ b/assets/badges/coverage-lines.svg @@ -0,0 +1,21 @@ + + Line Coverage: 10% + + + + + + + + + + + + + + + Line Coverage + + 10% + + diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg new file mode 100644 index 0000000..ed493cd --- /dev/null +++ b/assets/badges/coverage-methods.svg @@ -0,0 +1,21 @@ + + Method Coverage: 3.9% + + + + + + + + + + + + + + + Method Coverage + + 3.9% + + diff --git a/src/PodmanClient.DotNet.nuspec b/src/PodmanClient.DotNet.nuspec deleted file mode 100644 index 63e5286d8deb17fc62f7b994e94512761283b51a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1914 zcmds&&2AGx5QOW@Q;d89B-;>iK-g@A3p)G9*pqSEXF$y zkpd@#Ebpv)x~r?Zdd64Z`#RPaEwxb4r3#fg*Ia9@^i(Z9R7Ve3kCo|;`r2ryvCeo- z@eR=$SofJf^OsC*Z@En3ZAmf~v72X{^XQ$Fh#OSNbj{TbmDYxt= zGqbb*;chc>s0*-XWN>Yn-jM%{w=GP9-tlX~-X%=7)HdU}jhKgVP0Y1E<6n2nh|ly2 zq_$q_eT+@_n~^0^pI|hHO`+GUW7ZXX3Q)%J*Cuvw72h*`gyACYyV5_8Z)Ts6dn4WLww3$rldWABB zYqp?o`bx$lbUxA}-fp8_(A@(~?nPC8m$S*qbxi3`2DAO7_L4sI;XVv-yFdFm_I2#MtIlI}wO;wJ zDRXuCt-ESO=gxJXkDnd7zTo42drC@b_C0BK?$`(~-y@YC +/// Image build endpoints (including streaming progress). +/// +public interface IPodmanBuildClient { + Task> BuildImageAsync( + string dockerfile, + Stream? context = null, + bool pull = false, + bool rm = true, + bool forcerm = false, + bool nocache = false, + string? remote = null, + string? t = null, + string? platform = null, + string? buildargs = null, + string? labels = null, + CancellationToken cancellationToken = default + ); + + Task?>> BuildImageWithProgressAsync( + string dockerfile, + Stream? context = null, + bool pull = false, + bool rm = true, + bool forcerm = false, + bool nocache = false, + string? remote = null, + string? t = null, + string? platform = null, + string? buildargs = null, + string? labels = null, + CancellationToken cancellationToken = default + ); +} diff --git a/src/PodmanClient/Abstractions/IPodmanContainersClient.cs b/src/PodmanClient/Abstractions/IPodmanContainersClient.cs new file mode 100644 index 0000000..f27e50e --- /dev/null +++ b/src/PodmanClient/Abstractions/IPodmanContainersClient.cs @@ -0,0 +1,244 @@ +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; + +/// +/// Container lifecycle, inspection, attach, logs, archive, and related libpod endpoints. +/// +public interface IPodmanContainersClient { + Task> CreateContainerAsync( + string name, + string image, + List? command = null, + Dictionary? env = null, + bool? remove = null, + bool? stdin = null, + bool? terminal = null, + List? mounts = null, + bool? privileged = null, + string? hostname = null, + Namespace? netns = null, + List? portMappings = null, + string? restartPolicy = null, + ulong? stopTimeout = null, + List? capAdd = null, + List? capDrop = null, + List? dnsServers = null, + List? dnsSearch = null, + List? dnsOptions = null, + bool? publishImagePorts = null, + List? cniNetworks = null, + Dictionary? labels = null, + bool? readOnlyFilesystem = null, + List? rLimits = null, + List? devices = null, + string? ociRuntime = null, + string? pod = null, + bool? noNewPrivileges = null, + string? cgroupsMode = null, + Dictionary? storageOpts = null, + bool? unsetenvall = null, + Dictionary? secretEnv = null, + string? timezone = null, + Dictionary? sysctl = null, + string? seccompProfilePath = null, + string? seccompPolicy = null, + Dictionary? annotations = null, + string? apparmorProfile = null, + string? baseHostsFile = null, + string? cgroupParent = null, + Namespace? cgroupns = null, + List? chrootDirectories = null, + string? conmonPidFile = null, + List? containerCreateCommand = null, + bool? createWorkingDir = null, + List? dependencyContainers = null, + List? deviceCgroupRule = null, + List? devicesFrom = null, + List? entrypoint = null, + bool? envHost = null, + List? envMerge = null, + Dictionary? expose = null, + string? groupEntry = null, + List? groups = null, + long? healthCheckOnFailureAction = null, + Schema2HealthConfig? healthConfig = null, + List? hostDeviceList = null, + List? hostAdd = null, + List? hostUsers = null, + bool? envHTTPProxy = null, + IDMappingOptions? idMappings = null, + string? imageArch = null, + string? imageOS = null, + string? imageVariant = null, + string? imageVolumeMode = null, + List? imageVolumes = null, + bool? init = null, + string? initContainerType = null, + string? initPath = null, + LinuxIntelRdt? intelRdt = null, + Namespace? ipcns = null, + bool? labelNested = null, + LogConfigLibpod? logConfiguration = null, + bool? managePassword = null, + List? mask = null, + Dictionary? networkOptions = null, + Dictionary? networks = null, + long? oomScoreAdj = null, + List? overlayVolumes = null, + string? passwdEntry = null, + LinuxPersonality? personality = null, + Namespace? pidns = null, + string? rawImageName = null, + bool? readWriteTmpfs = null, + LinuxResources? resourceLimits = null, + ulong? restartTries = null, + string? rootfs = null, + string? rootfsMapping = null, + bool? rootfsOverlay = null, + string? rootfsPropagation = null, + string? sdnotifyMode = null, + List? secrets = null, + List? selinuxOpts = null, + long? shmSize = null, + long? shmSizeSystemd = null, + StartupHealthConfig? startupHealthConfig = null, + long? stopSignal = null, + string? systemd = null, + Dictionary? throttleReadBpsDevice = null, + Dictionary? throttleReadIopsDevice = null, + Dictionary? throttleWriteBpsDevice = null, + Dictionary? throttleWriteIopsDevice = null, + ulong? timeout = null, + string? umask = null, + Dictionary? unified = null, + List? unmask = null, + bool? useImageHosts = null, + bool? useImageResolvConf = null, + string? user = null, + Namespace? userns = null, + Namespace? utsns = null, + bool? volatileFlag = null, + List? volumes = null, + List? volumesFrom = null, + Dictionary? weightDevice = null, + string? workDir = null + ); + + Task StartContainerAsync(string containerId, string detachKeys = "ctrl-p,ctrl-q"); + Task StopContainerAsync(string containerId, int timeout = 10, bool ignoreAlreadyStopped = false); + Task> ForceDeleteContainerAsync(string containerId, bool deleteVolumes = false, int timeout = 10); + Task> DeleteContainerAsync(string containerId, bool depend = false, bool ignore = false, int timeout = 10); + Task ExtractArchiveToContainerAsync(string containerId, Stream tarStream, string path, bool pause = true); + + Task?>> ListContainersAsync( + bool all = false, + int? limit = null, + bool size = false, + bool sync = false, + string? filters = null, + CancellationToken cancellationToken = default + ); + + Task> InspectContainerAsync(string name, CancellationToken cancellationToken = default); + Task ContainerExistsAsync(string name, CancellationToken cancellationToken = default); + Task RestartContainerAsync(string name, int timeout = 10, CancellationToken cancellationToken = default); + Task KillContainerAsync(string name, string signal = "TERM", CancellationToken cancellationToken = default); + Task PauseContainerAsync(string name, CancellationToken cancellationToken = default); + Task UnpauseContainerAsync(string name, CancellationToken cancellationToken = default); + Task> WaitContainerAsync(string name, string? condition = null, CancellationToken cancellationToken = default); + Task> GetContainerLogsAsync( + string name, + bool follow = false, + bool stdout = true, + bool stderr = true, + bool timestamps = false, + string? since = null, + string? until = null, + string? tail = null, + CancellationToken cancellationToken = default + ); + + Task> GetContainerStatsAsync(string name, bool stream = false, CancellationToken cancellationToken = default); + Task?>> GetContainersStatsAsync( + IEnumerable? containers = null, + bool stream = false, + 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( + string name, + bool keep = false, + bool leaveRunning = false, + bool tcpEstablished = false, + bool export = false, + bool ignoreRootFS = false, + CancellationToken cancellationToken = default + ); + + Task RestoreContainerAsync( + string name, + string? importPath = null, + bool keep = false, + bool leaveRunning = false, + bool tcpEstablished = false, + bool ignoreRootFS = false, + bool ignoreStaticIP = false, + bool ignoreStaticMAC = false, + CancellationToken cancellationToken = default + ); + + Task> MountContainerAsync(string name, CancellationToken cancellationToken = default); + Task UnmountContainerAsync(string name, CancellationToken cancellationToken = default); + Task> ExportContainerAsync(string name, CancellationToken cancellationToken = default); + Task> GetContainerArchiveAsync(string name, string path, CancellationToken cancellationToken = default); + Task PutContainerArchiveAsync(string containerId, Stream tarStream, string path, bool pause = true, CancellationToken cancellationToken = default); + Task> AttachContainerAsync( + string name, + bool logs = false, + bool stream = true, + bool stdout = true, + bool stderr = true, + bool stdin = false, + string? detachKeys = null, + CancellationToken cancellationToken = default + ); + + /// + /// Opens a full-duplex attach session (multiplexed or raw TTY) over a hijacked HTTP connection. + /// + Task> AttachContainerSessionAsync( + string name, + bool logs = false, + bool stream = true, + bool stdout = true, + bool stderr = true, + bool stdin = true, + bool tty = false, + string? detachKeys = null, + CancellationToken cancellationToken = default + ); + + Task> GetContainerChangesAsync(string name, CancellationToken cancellationToken = default); + Task> CommitContainerAsync( + string container, + string? repo = null, + string? tag = null, + string? comment = null, + string? author = null, + bool pause = true, + IEnumerable? changes = null, + string? format = null, + CancellationToken cancellationToken = default + ); + + Task> HealthCheckContainerAsync(string name, 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/IPodmanExecClient.cs b/src/PodmanClient/Abstractions/IPodmanExecClient.cs new file mode 100644 index 0000000..b041cdf --- /dev/null +++ b/src/PodmanClient/Abstractions/IPodmanExecClient.cs @@ -0,0 +1,44 @@ +using MaksIT.PodmanClientDotNet.Dtos.Exec; +using MaksIT.PodmanClientDotNet.Streaming; +using MaksIT.Results; + +/// +/// Exec session create, start, resize, and inspect endpoints. +/// +public interface IPodmanExecClient { + Task> CreateExecAsync( + string containerName, + string[] cmd, + bool attachStderr = true, + bool attachStdin = false, + bool attachStdout = true, + string? detachKeys = null, + string[]? env = null, + bool privileged = false, + bool tty = false, + string? user = null, + string? workingDir = null + ); + + Task StartExecAsync( + string execId, + bool detach = false, + bool tty = false, + int? height = null, + int? width = null + ); + + Task> InspectExecAsync(string execId); + Task ResizeExecAsync(string execId, int height, int width, CancellationToken cancellationToken = default); + + /// + /// Starts exec and returns a full-duplex attach session (requires detach=false on the wire). + /// + Task> StartExecSessionAsync( + string execId, + bool tty = false, + int? height = null, + int? width = null, + CancellationToken cancellationToken = default + ); +} diff --git a/src/PodmanClient/Abstractions/IPodmanGenerateClient.cs b/src/PodmanClient/Abstractions/IPodmanGenerateClient.cs new file mode 100644 index 0000000..ecb16ea --- /dev/null +++ b/src/PodmanClient/Abstractions/IPodmanGenerateClient.cs @@ -0,0 +1,34 @@ +using MaksIT.PodmanClientDotNet.Dtos.Generate; +using MaksIT.Results; + +/// +/// Generate systemd units, kube YAML, and play-kube endpoints. +/// +public interface IPodmanGenerateClient { + Task> GenerateSystemdAsync( + string name, + bool useName = false, + bool createNew = false, + int? restartSec = null, + string? restartPolicy = null, + string? containerPrefix = null, + string? podPrefix = null, + string? separator = null, + CancellationToken cancellationToken = default + ); + + Task> GenerateKubeAsync( + IEnumerable names, + bool service = false, + CancellationToken cancellationToken = default + ); + + Task> PlayKubeAsync( + Stream yaml, + string? network = null, + bool tlsVerify = true, + bool start = true, + string? logDriver = null, + CancellationToken cancellationToken = default + ); +} diff --git a/src/PodmanClient/Abstractions/IPodmanImagesClient.cs b/src/PodmanClient/Abstractions/IPodmanImagesClient.cs new file mode 100644 index 0000000..e97afd0 --- /dev/null +++ b/src/PodmanClient/Abstractions/IPodmanImagesClient.cs @@ -0,0 +1,86 @@ +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.Image; +using MaksIT.PodmanClientDotNet.Streaming; +using MaksIT.Results; + +/// +/// Image pull, push, list, inspect, tag, search, load, import, export, and prune endpoints. +/// +public interface IPodmanImagesClient { + Task PullImageAsync( + string reference, + bool tlsVerify = true, + bool quiet = false, + string policy = "always", + string? arch = null, + string? os = null, + string? variant = null, + bool allTags = false, + string? authHeader = null + ); + + Task TagImageAsync(string image, string repo, string tag); + + Task?>> ListImagesAsync( + bool all = false, + string? filters = null, + CancellationToken cancellationToken = default + ); + + 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( + IEnumerable images, + bool all = false, + bool force = false, + CancellationToken cancellationToken = default + ); + + Task> PruneImagesAsync(CancellationToken cancellationToken = default); + Task?>> SearchImagesAsync(string term, int? limit = null, CancellationToken cancellationToken = default); + Task PushImageAsync( + string name, + string? destination = null, + bool tlsVerify = true, + bool compress = false, + string? authHeader = null, + CancellationToken cancellationToken = default + ); + + Task UntagImageAsync(string name, string? repo = null, string? tag = null, CancellationToken cancellationToken = default); + Task?>> GetImageHistoryAsync(string name, CancellationToken cancellationToken = default); + Task> GetImageTreeAsync(string name, CancellationToken cancellationToken = default); + Task> GetImageChangesAsync(string name, CancellationToken cancellationToken = default); + Task> ImportImageAsync( + Stream? tarball = null, + string? changes = null, + string? message = null, + string? reference = null, + string? url = null, + CancellationToken cancellationToken = default + ); + + Task> LoadImageAsync(Stream tarball, CancellationToken cancellationToken = default); + Task> ExportImagesAsync( + IEnumerable references, + string? format = null, + bool compress = false, + CancellationToken cancellationToken = default + ); + + Task> GetImageAsync(string name, string? format = null, bool compress = false, CancellationToken cancellationToken = default); + + Task?>> PullImageWithProgressAsync( + string reference, + bool tlsVerify = true, + bool quiet = false, + string policy = "always", + string? arch = null, + string? os = null, + string? variant = null, + bool allTags = false, + string? authHeader = null, + CancellationToken cancellationToken = default + ); +} diff --git a/src/PodmanClient/Abstractions/IPodmanManifestsClient.cs b/src/PodmanClient/Abstractions/IPodmanManifestsClient.cs new file mode 100644 index 0000000..05856b6 --- /dev/null +++ b/src/PodmanClient/Abstractions/IPodmanManifestsClient.cs @@ -0,0 +1,19 @@ +using MaksIT.PodmanClientDotNet.Dtos.Manifest; +using MaksIT.Results; + +/// +/// Manifest list create, inspect, add, push, and delete endpoints. +/// +public interface IPodmanManifestsClient { + Task> CreateManifestAsync( + string name, + string? image = null, + bool all = false, + CancellationToken cancellationToken = default + ); + + Task DeleteManifestAsync(string name, string? digest = null, CancellationToken cancellationToken = default); + Task> InspectManifestAsync(string name, CancellationToken cancellationToken = default); + Task AddToManifestAsync(string name, ManifestAddRequestDto request, CancellationToken cancellationToken = default); + Task PushManifestAsync(string name, string destination, bool all = false, CancellationToken cancellationToken = default); +} diff --git a/src/PodmanClient/Abstractions/IPodmanNetworksClient.cs b/src/PodmanClient/Abstractions/IPodmanNetworksClient.cs new file mode 100644 index 0000000..4c86ee0 --- /dev/null +++ b/src/PodmanClient/Abstractions/IPodmanNetworksClient.cs @@ -0,0 +1,15 @@ +using MaksIT.PodmanClientDotNet.Dtos.Network; +using MaksIT.PodmanClientDotNet.Models.Network; +using MaksIT.Results; + +/// +/// Network create, list, inspect, delete, connect, and disconnect endpoints. +/// +public interface IPodmanNetworksClient { + Task> CreateNetworkAsync(NetworkCreateRequest request, CancellationToken cancellationToken = default); + Task?>> ListNetworksAsync(CancellationToken cancellationToken = default); + Task> InspectNetworkAsync(string name, CancellationToken cancellationToken = default); + Task DeleteNetworkAsync(string name, CancellationToken cancellationToken = default); + Task ConnectNetworkAsync(string name, NetworkConnectRequest request, CancellationToken cancellationToken = default); + Task DisconnectNetworkAsync(string name, NetworkDisconnectRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/PodmanClient/Abstractions/IPodmanPodsClient.cs b/src/PodmanClient/Abstractions/IPodmanPodsClient.cs new file mode 100644 index 0000000..1fd9e39 --- /dev/null +++ b/src/PodmanClient/Abstractions/IPodmanPodsClient.cs @@ -0,0 +1,24 @@ +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.Pod; +using MaksIT.PodmanClientDotNet.Models.Pod; +using MaksIT.Results; + +/// +/// Pod create, list, inspect, lifecycle, stats, and prune endpoints. +/// +public interface IPodmanPodsClient { + Task> CreatePodAsync(PodCreateRequest request, CancellationToken cancellationToken = default); + Task?>> ListPodsAsync(bool all = false, CancellationToken cancellationToken = default); + Task> InspectPodAsync(string name, CancellationToken cancellationToken = default); + Task PodExistsAsync(string name, CancellationToken cancellationToken = default); + Task DeletePodAsync(string name, bool force = false, CancellationToken cancellationToken = default); + Task StartPodAsync(string name, CancellationToken cancellationToken = default); + Task StopPodAsync(string name, int timeout = 10, CancellationToken cancellationToken = default); + Task RestartPodAsync(string name, int timeout = 10, CancellationToken cancellationToken = default); + 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> TopPodAsync(string name, CancellationToken cancellationToken = default); + Task> GetPodsStatsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/PodmanClient/Abstractions/IPodmanSystemClient.cs b/src/PodmanClient/Abstractions/IPodmanSystemClient.cs new file mode 100644 index 0000000..e0df109 --- /dev/null +++ b/src/PodmanClient/Abstractions/IPodmanSystemClient.cs @@ -0,0 +1,15 @@ +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.System; +using MaksIT.Results; + +/// +/// Podman system endpoints: ping, version, info, events, disk usage, and prune. +/// +public interface IPodmanSystemClient { + Task> PingAsync(CancellationToken cancellationToken = default); + Task> GetVersionAsync(CancellationToken cancellationToken = default); + Task> GetInfoAsync(CancellationToken cancellationToken = default); + Task> GetSystemDiskUsageAsync(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 new file mode 100644 index 0000000..bb1b12c --- /dev/null +++ b/src/PodmanClient/Abstractions/IPodmanVolumesClient.cs @@ -0,0 +1,15 @@ +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.Volume; +using MaksIT.PodmanClientDotNet.Models.Volume; +using MaksIT.Results; + +/// +/// Volume create, list, inspect, delete, and prune endpoints. +/// +public interface IPodmanVolumesClient { + Task> CreateVolumeAsync(CreateVolumeRequest request, CancellationToken cancellationToken = default); + 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); +} diff --git a/src/PodmanClient/Dtos/Build/BuildProgressLineDto.cs b/src/PodmanClient/Dtos/Build/BuildProgressLineDto.cs new file mode 100644 index 0000000..6feca93 --- /dev/null +++ b/src/PodmanClient/Dtos/Build/BuildProgressLineDto.cs @@ -0,0 +1,11 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Build; + +/// +/// A single NDJSON line from the image build stream. +/// +public sealed class BuildProgressLineDto { + public string? Stream { get; set; } + public string? Error { get; set; } + public string? Status { get; set; } + public string? Id { get; set; } +} diff --git a/src/PodmanClient/Dtos/Build/BuildReportDto.cs b/src/PodmanClient/Dtos/Build/BuildReportDto.cs new file mode 100644 index 0000000..6dff587 --- /dev/null +++ b/src/PodmanClient/Dtos/Build/BuildReportDto.cs @@ -0,0 +1,9 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Build; +/// +/// Deserialized Podman libpod API payload (Build Report). +/// + +public sealed class BuildReportDto { + public string? Id { get; set; } + public string[]? Names { get; set; } +} diff --git a/src/PodmanClient/Dtos/Common/ErrorResponseDto.cs b/src/PodmanClient/Dtos/Common/ErrorResponseDto.cs new file mode 100644 index 0000000..3fe4f97 --- /dev/null +++ b/src/PodmanClient/Dtos/Common/ErrorResponseDto.cs @@ -0,0 +1,9 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Common; +/// +/// Deserialized Podman libpod API payload (Error response). +/// + +public sealed class ErrorResponseDto { + public string? Cause { get; set; } + public string? Message { get; set; } +} diff --git a/src/PodmanClient/Dtos/Common/IdResponseDto.cs b/src/PodmanClient/Dtos/Common/IdResponseDto.cs new file mode 100644 index 0000000..d7d1053 --- /dev/null +++ b/src/PodmanClient/Dtos/Common/IdResponseDto.cs @@ -0,0 +1,8 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Common; +/// +/// Deserialized Podman libpod API payload (Id response). +/// + +public sealed class IdResponseDto { + public string? Id { get; set; } +} diff --git a/src/PodmanClient/Dtos/Common/PruneReportDto.cs b/src/PodmanClient/Dtos/Common/PruneReportDto.cs new file mode 100644 index 0000000..b167cdf --- /dev/null +++ b/src/PodmanClient/Dtos/Common/PruneReportDto.cs @@ -0,0 +1,14 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Common; + +/// Prune operation report (containers, images, volumes, pods, networks). +public sealed class PruneReportDto { + public string[]? Id { get; set; } + public string[]? IdDeleted { get; set; } + public ulong? Size { get; set; } + public ulong? SpaceReclaimed { get; set; } + public string[]? PodsDeleted { get; set; } + public string[]? NetworksDeleted { get; set; } + public string[]? VolumesDeleted { get; set; } + public string[]? ImagesDeleted { get; set; } + public string[]? ContainersDeleted { get; set; } +} diff --git a/src/PodmanClient/Dtos/Common/ReportDto.cs b/src/PodmanClient/Dtos/Common/ReportDto.cs new file mode 100644 index 0000000..5f06da8 --- /dev/null +++ b/src/PodmanClient/Dtos/Common/ReportDto.cs @@ -0,0 +1,9 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Common; +/// +/// Deserialized Podman libpod API payload (Report). +/// + +public sealed class ReportDto { + public string[]? Id { get; set; } + public Dictionary? Err { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/ContainerChangesDto.cs b/src/PodmanClient/Dtos/Container/ContainerChangesDto.cs new file mode 100644 index 0000000..210c9a2 --- /dev/null +++ b/src/PodmanClient/Dtos/Container/ContainerChangesDto.cs @@ -0,0 +1,5 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Container; + +/// Podman returns a JSON array of filesystem change paths. +public sealed class ContainerChangesDto : List { +} diff --git a/src/PodmanClient/Dtos/Container/ContainerCommitDto.cs b/src/PodmanClient/Dtos/Container/ContainerCommitDto.cs new file mode 100644 index 0000000..c34b87d --- /dev/null +++ b/src/PodmanClient/Dtos/Container/ContainerCommitDto.cs @@ -0,0 +1,8 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Container; +/// +/// Deserialized Podman libpod API payload (Container Commit). +/// + +public sealed class ContainerCommitDto { + public string? Id { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/ContainerHealthCheckDto.cs b/src/PodmanClient/Dtos/Container/ContainerHealthCheckDto.cs new file mode 100644 index 0000000..1138e3c --- /dev/null +++ b/src/PodmanClient/Dtos/Container/ContainerHealthCheckDto.cs @@ -0,0 +1,20 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Container; +/// +/// Deserialized Podman libpod API payload (Container Health Check). +/// + +public sealed class ContainerHealthCheckDto { + public string? Status { get; set; } + public int FailingStreak { get; set; } + public ContainerHealthLogDto[]? Log { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Container Health Log). +/// + +public sealed class ContainerHealthLogDto { + public string? Start { get; set; } + public string? End { get; set; } + public int ExitCode { get; set; } + public string? Output { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/ContainerInspectDto.cs b/src/PodmanClient/Dtos/Container/ContainerInspectDto.cs new file mode 100644 index 0000000..7e0339d --- /dev/null +++ b/src/PodmanClient/Dtos/Container/ContainerInspectDto.cs @@ -0,0 +1,65 @@ +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; } + public string? Image { get; set; } + public string? ImageName { get; set; } + public string? Pod { get; set; } + public string? PodName { get; set; } + public ContainerStateDto? State { get; set; } + public ContainerConfigDto? Config { get; set; } + public string? MountLabel { get; set; } + public string? ProcessLabel { get; set; } + public string? AppArmorProfile { get; set; } + public string? HostnamePath { get; set; } + public string? HostsPath { get; set; } + public string? ResolvConfPath { get; set; } + public string? Driver { get; set; } + public string? OCIConfigPath { get; set; } + public string? OCIRuntime { get; set; } + public long Created { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Container State). +/// + +public sealed class ContainerStateDto { + public string? Status { get; set; } + public bool Running { get; set; } + public bool Paused { get; set; } + public bool Restarting { get; set; } + public bool OOMKilled { get; set; } + public bool Dead { get; set; } + public int Pid { get; set; } + public int ExitCode { get; set; } + 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; } + public string? User { get; set; } + public bool AttachStdin { get; set; } + public bool AttachStdout { get; set; } + public bool AttachStderr { get; set; } + public string? Tty { get; set; } + public bool OpenStdin { get; set; } + public bool StdinOnce { get; set; } + public string[]? Env { get; set; } + public string[]? Cmd { get; set; } + public string? Image { get; set; } + public Dictionary? Labels { get; set; } + public string? WorkingDir { get; set; } + public bool NetworkDisabled { get; set; } + public string? StopSignal { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/ContainerListEntryDto.cs b/src/PodmanClient/Dtos/Container/ContainerListEntryDto.cs new file mode 100644 index 0000000..4b81973 --- /dev/null +++ b/src/PodmanClient/Dtos/Container/ContainerListEntryDto.cs @@ -0,0 +1,18 @@ +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? State { get; set; } + public string? Status { get; set; } + public string? Pod { get; set; } + public string? PodName { get; set; } + public bool AutoRemove { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/ContainerMountDto.cs b/src/PodmanClient/Dtos/Container/ContainerMountDto.cs new file mode 100644 index 0000000..e4b3636 --- /dev/null +++ b/src/PodmanClient/Dtos/Container/ContainerMountDto.cs @@ -0,0 +1,8 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Container; +/// +/// Deserialized Podman libpod API payload (Container Mount). +/// + +public sealed class ContainerMountDto { + public string? Id { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/ContainerStatsDto.cs b/src/PodmanClient/Dtos/Container/ContainerStatsDto.cs new file mode 100644 index 0000000..eae1810 --- /dev/null +++ b/src/PodmanClient/Dtos/Container/ContainerStatsDto.cs @@ -0,0 +1,41 @@ +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; } +} +/// +/// Deserialized Podman libpod API payload (Container Stats Cpu). +/// + +public sealed class ContainerStatsCpuDto { + public ulong TotalUsage { get; set; } + public ulong SystemUsage { get; set; } + public ulong KernelMode { get; set; } + public ulong UserMode { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Container Stats Memory). +/// + +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). +/// + +public sealed class ContainerStatsNetworkDto { + public ulong RxBytes { get; set; } + public ulong TxBytes { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/ContainerTopDto.cs b/src/PodmanClient/Dtos/Container/ContainerTopDto.cs new file mode 100644 index 0000000..1683628 --- /dev/null +++ b/src/PodmanClient/Dtos/Container/ContainerTopDto.cs @@ -0,0 +1,9 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Container; +/// +/// Deserialized Podman libpod API payload (Container Top). +/// + +public sealed class ContainerTopDto { + public string[]? Titles { get; set; } + public List? Processes { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/ContainerWaitDto.cs b/src/PodmanClient/Dtos/Container/ContainerWaitDto.cs new file mode 100644 index 0000000..f521d61 --- /dev/null +++ b/src/PodmanClient/Dtos/Container/ContainerWaitDto.cs @@ -0,0 +1,16 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Container; +/// +/// Deserialized Podman libpod API payload (Container Wait). +/// + +public sealed class ContainerWaitDto { + public long StatusCode { get; set; } + public ContainerWaitErrorDto? Error { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Container Wait Error). +/// + +public sealed class ContainerWaitErrorDto { + public string? Message { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/CreateContainerResponseDto.cs b/src/PodmanClient/Dtos/Container/CreateContainerResponseDto.cs new file mode 100644 index 0000000..e706c18 --- /dev/null +++ b/src/PodmanClient/Dtos/Container/CreateContainerResponseDto.cs @@ -0,0 +1,9 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Container; +/// +/// Deserialized Podman libpod API payload (Create Container response). +/// + +public sealed class CreateContainerResponseDto { + public string? Id { get; set; } + public string[]? Warnings { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/DeleteContainerResponseDto.cs b/src/PodmanClient/Dtos/Container/DeleteContainerResponseDto.cs new file mode 100644 index 0000000..8583351 --- /dev/null +++ b/src/PodmanClient/Dtos/Container/DeleteContainerResponseDto.cs @@ -0,0 +1,9 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Container; +/// +/// Deserialized Podman libpod API payload (Delete Container response). +/// + +public sealed class DeleteContainerResponseDto { + public string? Err { get; set; } + public string? Id { get; set; } +} diff --git a/src/PodmanClient/Dtos/Container/MountedContainerDto.cs b/src/PodmanClient/Dtos/Container/MountedContainerDto.cs new file mode 100644 index 0000000..e4d6ba2 --- /dev/null +++ b/src/PodmanClient/Dtos/Container/MountedContainerDto.cs @@ -0,0 +1,17 @@ +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). +/// + +public sealed class MountedContainersResponseDto { + public List? Containers { get; set; } +} diff --git a/src/PodmanClient/Dtos/Exec/ExecModelsDto.cs b/src/PodmanClient/Dtos/Exec/ExecModelsDto.cs new file mode 100644 index 0000000..d120bc6 --- /dev/null +++ b/src/PodmanClient/Dtos/Exec/ExecModelsDto.cs @@ -0,0 +1,17 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Exec; +/// +/// Deserialized Podman libpod API payload (Create Exec response). +/// + +public sealed class CreateExecResponseDto { + public string? Id { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Inspect Exec response). +/// + +public sealed class InspectExecResponseDto { + public bool Running { get; set; } + public int ExitCode { get; set; } + public string? ProcessConfig { get; set; } +} diff --git a/src/PodmanClient/Dtos/Generate/GenerateModelsDto.cs b/src/PodmanClient/Dtos/Generate/GenerateModelsDto.cs new file mode 100644 index 0000000..99fb45f --- /dev/null +++ b/src/PodmanClient/Dtos/Generate/GenerateModelsDto.cs @@ -0,0 +1,27 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Generate; +/// +/// Deserialized Podman libpod API payload (Generate Systemd). +/// + +public sealed class GenerateSystemdDto { + public Dictionary? Units { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Systemd Unit). +/// + +public sealed class SystemdUnitDto { + public string? Name { get; set; } + public string? Content { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Play Kube Report). +/// + +public sealed class PlayKubeReportDto { + public string? Pod { get; set; } + public string[]? Containers { get; set; } + public string[]? Volumes { get; set; } + public string[]? Secrets { get; set; } + public string[]? Networks { get; set; } +} diff --git a/src/PodmanClient/Dtos/Image/ImageChangesDto.cs b/src/PodmanClient/Dtos/Image/ImageChangesDto.cs new file mode 100644 index 0000000..f2fc0cc --- /dev/null +++ b/src/PodmanClient/Dtos/Image/ImageChangesDto.cs @@ -0,0 +1,5 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Image; + +/// Podman returns a JSON array of filesystem change paths. +public sealed class ImageChangesDto : List { +} diff --git a/src/PodmanClient/Dtos/Image/ImageDeleteDto.cs b/src/PodmanClient/Dtos/Image/ImageDeleteDto.cs new file mode 100644 index 0000000..06d9947 --- /dev/null +++ b/src/PodmanClient/Dtos/Image/ImageDeleteDto.cs @@ -0,0 +1,10 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Image; +/// +/// Deserialized Podman libpod API payload (Image Delete). +/// + +public sealed class ImageDeleteDto { + public string? Deleted { get; set; } + public string[]? Untagged { get; set; } + public int ExitCode { get; set; } +} diff --git a/src/PodmanClient/Dtos/Image/ImageHistoryEntryDto.cs b/src/PodmanClient/Dtos/Image/ImageHistoryEntryDto.cs new file mode 100644 index 0000000..5d362ac --- /dev/null +++ b/src/PodmanClient/Dtos/Image/ImageHistoryEntryDto.cs @@ -0,0 +1,13 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Image; +/// +/// Deserialized Podman libpod API payload (Image History Entry). +/// + +public sealed class ImageHistoryEntryDto { + public string? Id { get; set; } + public long Created { get; set; } + public string? CreatedBy { get; set; } + public string[]? Tags { get; set; } + public long Size { get; set; } + public string? Comment { get; set; } +} diff --git a/src/PodmanClient/Dtos/Image/ImageImportDto.cs b/src/PodmanClient/Dtos/Image/ImageImportDto.cs new file mode 100644 index 0000000..e6e6c58 --- /dev/null +++ b/src/PodmanClient/Dtos/Image/ImageImportDto.cs @@ -0,0 +1,9 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Image; +/// +/// Deserialized Podman libpod API payload (Image Import). +/// + +public sealed class ImageImportDto { + public string? Id { get; set; } + public string[]? Repotags { get; set; } +} diff --git a/src/PodmanClient/Dtos/Image/ImageInspectDto.cs b/src/PodmanClient/Dtos/Image/ImageInspectDto.cs new file mode 100644 index 0000000..0eaf1e3 --- /dev/null +++ b/src/PodmanClient/Dtos/Image/ImageInspectDto.cs @@ -0,0 +1,40 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Image; +/// +/// Deserialized Podman libpod API payload (Image Inspect). +/// + +public sealed class ImageInspectDto { + public string? Id { get; set; } + public string[]? RepoTags { get; set; } + public string[]? RepoDigests { get; set; } + public long Size { get; set; } + public string? Digest { get; set; } + public string? Parent { get; set; } + public string? Comment { get; set; } + public string? Created { get; set; } + public ImageConfigDto? Config { get; set; } + public ImageRootFsDto? RootFS { get; set; } + public string? Architecture { get; set; } + public string? Os { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Image Config). +/// + +public sealed class ImageConfigDto { + public string? Hostname { get; set; } + public string? User { get; set; } + public string[]? Env { get; set; } + public string[]? Cmd { get; set; } + public string[]? Entrypoint { get; set; } + public string? WorkingDir { get; set; } + public Dictionary? Labels { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Image Root Fs). +/// + +public sealed class ImageRootFsDto { + public string? Type { get; set; } + public string[]? Layers { get; set; } +} diff --git a/src/PodmanClient/Dtos/Image/ImageListEntryDto.cs b/src/PodmanClient/Dtos/Image/ImageListEntryDto.cs new file mode 100644 index 0000000..82a8b94 --- /dev/null +++ b/src/PodmanClient/Dtos/Image/ImageListEntryDto.cs @@ -0,0 +1,27 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Image; +/// +/// Deserialized Podman libpod API payload (Image List Entry). +/// + +public sealed class ImageListEntryDto { + public string? Id { get; set; } + public string[]? Names { 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; } +} +/// +/// Deserialized Podman libpod API payload (Image Search Result). +/// + +public sealed class ImageSearchResultDto { + public string? Name { get; set; } + public string? Description { get; set; } + public int Stars { get; set; } + public bool IsOfficial { get; set; } + public bool IsAutomated { get; set; } +} diff --git a/src/PodmanClient/Dtos/Image/ImageLoadDto.cs b/src/PodmanClient/Dtos/Image/ImageLoadDto.cs new file mode 100644 index 0000000..85b2caa --- /dev/null +++ b/src/PodmanClient/Dtos/Image/ImageLoadDto.cs @@ -0,0 +1,9 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Image; +/// +/// Deserialized Podman libpod API payload (Image Load). +/// + +public sealed class ImageLoadDto { + public string[]? Names { get; set; } + public string[]? LoadedImages { get; set; } +} diff --git a/src/PodmanClient/Dtos/Image/ImageRemoveResponseDto.cs b/src/PodmanClient/Dtos/Image/ImageRemoveResponseDto.cs new file mode 100644 index 0000000..9dbcde6 --- /dev/null +++ b/src/PodmanClient/Dtos/Image/ImageRemoveResponseDto.cs @@ -0,0 +1,8 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Image; +/// +/// Deserialized Podman libpod API payload (Image Remove response). +/// + +public sealed class ImageRemoveResponseDto { + public ImageDeleteDto[]? DeletedImages { get; set; } +} diff --git a/src/PodmanClient/Dtos/Image/ImageTreeDto.cs b/src/PodmanClient/Dtos/Image/ImageTreeDto.cs new file mode 100644 index 0000000..e5b0bbc --- /dev/null +++ b/src/PodmanClient/Dtos/Image/ImageTreeDto.cs @@ -0,0 +1,18 @@ +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; } +} diff --git a/src/PodmanClient/Dtos/Image/PullImageResponseDto.cs b/src/PodmanClient/Dtos/Image/PullImageResponseDto.cs new file mode 100644 index 0000000..ec1dd59 --- /dev/null +++ b/src/PodmanClient/Dtos/Image/PullImageResponseDto.cs @@ -0,0 +1,12 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Image; +/// +/// Deserialized Podman libpod API payload (Pull Image response). +/// + +public sealed class PullImageResponseDto { + public string? Error { get; set; } + public string? Id { get; set; } + public string? Status { get; set; } + public List? Images { get; set; } + public string? Stream { get; set; } +} diff --git a/src/PodmanClient/Dtos/Manifest/ManifestModelsDto.cs b/src/PodmanClient/Dtos/Manifest/ManifestModelsDto.cs new file mode 100644 index 0000000..69117ef --- /dev/null +++ b/src/PodmanClient/Dtos/Manifest/ManifestModelsDto.cs @@ -0,0 +1,36 @@ +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 string? Name { get; set; } + public ManifestListSpecDto[]? Manifests { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Manifest List Spec). +/// + +public sealed class ManifestListSpecDto { + public string? Digest { get; set; } + public string? Image { get; set; } + public string? Platform { get; set; } + public string? Os { get; set; } + public string? Arch { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Manifest Add request). +/// + +public sealed class ManifestAddRequestDto { + public string Image { get; set; } = ""; + public bool All { get; set; } + public string? Operation { get; set; } +} diff --git a/src/PodmanClient/Dtos/Network/NetworkModelsDto.cs b/src/PodmanClient/Dtos/Network/NetworkModelsDto.cs new file mode 100644 index 0000000..0add41f --- /dev/null +++ b/src/PodmanClient/Dtos/Network/NetworkModelsDto.cs @@ -0,0 +1,43 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Network; +/// +/// Deserialized Podman libpod API payload (Network List Entry). +/// + +public sealed class NetworkListEntryDto { + public string? Name { get; set; } + public string? Id { get; set; } + public string? Driver { get; set; } + public DateTime Created { get; set; } + public Dictionary? Labels { get; set; } + public Dictionary? Options { get; set; } + public bool? IPv6Enabled { get; set; } + public bool? Internal { get; set; } + public bool? DNSEnabled { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Network Inspect). +/// + +public sealed class NetworkInspectDto { + public string? Name { get; set; } + public string? Id { get; set; } + public string? Driver { get; set; } + public DateTime Created { get; set; } + public Dictionary? Labels { get; set; } + public Dictionary? Options { get; set; } + public bool? IPv6Enabled { get; set; } + public bool? Internal { get; set; } + public bool? DNSEnabled { get; set; } + public Dictionary? Containers { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Network Inspect Container). +/// + +public sealed class NetworkInspectContainerDto { + public string? Name { get; set; } + public string? EndpointID { get; set; } + public string? MacAddress { get; set; } + public string? IPv4Address { get; set; } + public string? IPv6Address { get; set; } +} diff --git a/src/PodmanClient/Dtos/Pod/PodModelsDto.cs b/src/PodmanClient/Dtos/Pod/PodModelsDto.cs new file mode 100644 index 0000000..e2f98a2 --- /dev/null +++ b/src/PodmanClient/Dtos/Pod/PodModelsDto.cs @@ -0,0 +1,70 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Pod; +/// +/// 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? CgroupParent { get; set; } + public DateTime Created { get; set; } + public Dictionary? Labels { get; set; } + public string? Namespace { get; set; } + public string? RestartPolicy { get; set; } + public ulong? StopTimeout { 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? Status { get; set; } + public string? CgroupParent { get; set; } + public DateTime 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; } +} +/// +/// 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). +/// + +public sealed class PodStatsDto { + public string? Id { get; set; } + public string? Name { get; set; } + public string? CPU { get; set; } + public string? MemUsage { get; set; } + public string? MemLimit { get; set; } + public string? MemPercent { get; set; } + public string? NetIO { get; set; } + public string? BlockIO { get; set; } + public string? PIDs { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Pod Stats response). +/// + +public sealed class PodStatsResponseDto { + public Dictionary? Stats { get; set; } +} diff --git a/src/PodmanClient/Dtos/System/InfoDto.cs b/src/PodmanClient/Dtos/System/InfoDto.cs new file mode 100644 index 0000000..ca98e9a --- /dev/null +++ b/src/PodmanClient/Dtos/System/InfoDto.cs @@ -0,0 +1,52 @@ +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 InfoPluginsDto? Plugins { 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? Hostname { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Info Store). +/// + +public sealed class InfoStoreDto { + 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; } +} +/// +/// Deserialized Podman libpod API payload (Info Plugins). +/// + +public sealed class InfoPluginsDto { + public string[]? Volume { get; set; } + public string[]? Network { get; set; } + public string[]? Log { get; set; } + public string[]? Authorization { get; set; } +} diff --git a/src/PodmanClient/Dtos/System/LibpodPingDto.cs b/src/PodmanClient/Dtos/System/LibpodPingDto.cs new file mode 100644 index 0000000..f53ad71 --- /dev/null +++ b/src/PodmanClient/Dtos/System/LibpodPingDto.cs @@ -0,0 +1,8 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.System; +/// +/// Deserialized Podman libpod API payload (Libpod Ping). +/// + +public sealed class LibpodPingDto { + public bool Ping { get; set; } +} diff --git a/src/PodmanClient/Dtos/System/LibpodVersionDto.cs b/src/PodmanClient/Dtos/System/LibpodVersionDto.cs new file mode 100644 index 0000000..5c25136 --- /dev/null +++ b/src/PodmanClient/Dtos/System/LibpodVersionDto.cs @@ -0,0 +1,18 @@ +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; } +} +/// +/// 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; } +} diff --git a/src/PodmanClient/Dtos/System/SystemDfDto.cs b/src/PodmanClient/Dtos/System/SystemDfDto.cs new file mode 100644 index 0000000..2f21d7f --- /dev/null +++ b/src/PodmanClient/Dtos/System/SystemDfDto.cs @@ -0,0 +1,18 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.System; +/// +/// Deserialized Podman libpod API payload (System Df). +/// + +public sealed class SystemDfDto { + 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/Dtos/Volume/VolumeModelsDto.cs b/src/PodmanClient/Dtos/Volume/VolumeModelsDto.cs new file mode 100644 index 0000000..d5551ab --- /dev/null +++ b/src/PodmanClient/Dtos/Volume/VolumeModelsDto.cs @@ -0,0 +1,46 @@ +namespace MaksIT.PodmanClientDotNet.Dtos.Volume; +/// +/// Deserialized Podman libpod API payload (Volume List Entry). +/// + +public sealed class VolumeListEntryDto { + public string? Name { get; set; } + public string? Driver { get; set; } + public string? Mountpoint { get; set; } + public DateTime Created { get; set; } + public Dictionary? Labels { get; set; } + public string? Scope { get; set; } + public VolumeListOptionsDto? Options { get; set; } + public VolumeUsageDataDto? UsageData { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Volume List Options). +/// + +public sealed class VolumeListOptionsDto { + public string? Device { get; set; } + public string? Type { get; set; } + public string? Label { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Volume Usage Data). +/// + +public sealed class VolumeUsageDataDto { + public long Size { get; set; } + public long RefCount { get; set; } +} +/// +/// Deserialized Podman libpod API payload (Volume Inspect response). +/// + +public sealed class VolumeInspectResponseDto { + public string? Name { get; set; } + public string? Driver { get; set; } + public string? Mountpoint { get; set; } + public DateTime Created { get; set; } + public Dictionary? Labels { get; set; } + public string? Scope { get; set; } + public VolumeListOptionsDto? Options { get; set; } + public VolumeUsageDataDto? UsageData { get; set; } +} diff --git a/src/PodmanClient/Extensions/ObjectExtensions.cs b/src/PodmanClient/Extensions/ObjectExtensions.cs deleted file mode 100644 index df68c2c..0000000 --- a/src/PodmanClient/Extensions/ObjectExtensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace MaksIT.PodmanClientDotNet.Extensions; - -public static class ObjectExtensions { - - /// - /// Converts object to json string - /// - /// - /// - /// - public static string ToJson(this T? obj) => ToJson(obj, null); - - /// - /// Converts object to json string - /// - /// - /// - /// - /// - public static string ToJson(T? obj, List? converters) { - if (obj == null) - return "{}"; - - var options = new JsonSerializerOptions { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - converters?.ForEach(x => options.Converters.Add(x)); - - return JsonSerializer.Serialize(obj, options); - } -} \ No newline at end of file diff --git a/src/PodmanClient/Extensions/ServiceCollectionExtensions.cs b/src/PodmanClient/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..cfa5d87 --- /dev/null +++ b/src/PodmanClient/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace MaksIT.PodmanClientDotNet.Extensions; + +/// +/// Registers with for dependency injection. +/// +public static class ServiceCollectionExtensions { + /// + /// Adds a typed for / . + /// + /// The service collection. + /// Podman API settings (bound from configuration in the host application). + public static void AddPodmanClient( + this IServiceCollection services, + IPodmanClientConfiguration podmanClientConfiguration + ) { + ArgumentNullException.ThrowIfNull(podmanClientConfiguration); + + if (string.IsNullOrWhiteSpace(podmanClientConfiguration.ServerUrl)) + throw new ArgumentException( + $"{nameof(IPodmanClientConfiguration.ServerUrl)} must be configured.", + nameof(podmanClientConfiguration) + ); + + services.AddSingleton(podmanClientConfiguration); + services.AddHttpClient(); + } +} diff --git a/src/PodmanClient/Extensions/StringExtensions.cs b/src/PodmanClient/Extensions/StringExtensions.cs deleted file mode 100644 index d05a08b..0000000 --- a/src/PodmanClient/Extensions/StringExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace MaksIT.PodmanClientDotNet.Extensions; - -public static partial class StringExtensions { - - - /// - /// Converts JSON string to object - /// - /// - /// - /// - public static T? ToObject(this string s) => ToObjectCore(s, null); - - /// - /// - /// - /// - /// - /// - /// - public static T? ToObject(this string s, List converters) => ToObjectCore(s, converters); - - private static T? ToObjectCore(string s, List? converters) { - var options = new JsonSerializerOptions { - PropertyNameCaseInsensitive = true - }; - - converters?.ForEach(x => options.Converters.Add(x)); - - return JsonSerializer.Deserialize(s, options); - } -} \ No newline at end of file diff --git a/src/PodmanClient/IPodmanClient.cs b/src/PodmanClient/IPodmanClient.cs new file mode 100644 index 0000000..77d1513 --- /dev/null +++ b/src/PodmanClient/IPodmanClient.cs @@ -0,0 +1,14 @@ +/// +/// Podman REST API client contract. +/// +public interface IPodmanClient + : IPodmanSystemClient, + IPodmanContainersClient, + IPodmanImagesClient, + IPodmanVolumesClient, + IPodmanNetworksClient, + IPodmanPodsClient, + IPodmanExecClient, + IPodmanBuildClient, + IPodmanManifestsClient, + IPodmanGenerateClient { } diff --git a/src/PodmanClient/IPodmanClientConfiguration.cs b/src/PodmanClient/IPodmanClientConfiguration.cs new file mode 100644 index 0000000..a5845ae --- /dev/null +++ b/src/PodmanClient/IPodmanClientConfiguration.cs @@ -0,0 +1,25 @@ +/// +/// Configuration for when registered via dependency injection. +/// Implement in the host application (for example an options class bound from appsettings.json). +/// +public interface IPodmanClientConfiguration { + /// + /// Recommended configuration section name (for example PodmanClient in appsettings.json). + /// + public const string SectionName = "PodmanClient"; + + /// + /// Base URL of the Podman API (for example http://localhost:8080). + /// + string ServerUrl { get; set; } + + /// + /// Podman API version segment used in request paths. Defaults to v1.41. + /// + string ApiVersion { get; set; } + + /// + /// HTTP request timeout in minutes. + /// + int TimeoutMinutes { get; set; } +} diff --git a/src/PodmanClient/Internal/PodmanHijackConnection.cs b/src/PodmanClient/Internal/PodmanHijackConnection.cs new file mode 100644 index 0000000..694b3a6 --- /dev/null +++ b/src/PodmanClient/Internal/PodmanHijackConnection.cs @@ -0,0 +1,96 @@ +using System.Net.Security; +using System.Net.Sockets; +using System.Text; + +namespace MaksIT.PodmanClientDotNet.Internal; + +internal static class PodmanHijackConnection { + public static async Task ConnectAsync( + Uri baseAddress, + string apiVersion, + HttpMethod method, + string libpodPath, + string? queryString, + byte[]? requestBody, + CancellationToken cancellationToken + ) { + ArgumentNullException.ThrowIfNull(baseAddress); + var host = baseAddress.Host; + var port = baseAddress.IsDefaultPort + ? baseAddress.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ? 443 : 80 + : baseAddress.Port; + + var tcpClient = new TcpClient(); + await tcpClient.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false); + + Stream stream = tcpClient.GetStream(); + if (baseAddress.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) { + var ssl = new SslStream(stream, leaveInnerStreamOpen: false); + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, cancellationToken) + .ConfigureAwait(false); + stream = ssl; + } + + var path = $"/{apiVersion.Trim('/')}{libpodPath}{queryString}"; + var request = BuildRequest(method, path, host, requestBody); + await stream.WriteAsync(request, cancellationToken).ConfigureAwait(false); + + await ReadResponseHeadersAsync(stream, cancellationToken).ConfigureAwait(false); + return new PodmanHijackStream(stream, tcpClient); + } + + private static byte[] BuildRequest(HttpMethod method, string path, string host, byte[]? body) { + var sb = new StringBuilder(); + sb.Append(method.Method).Append(' ').Append(path).Append(" HTTP/1.1\r\n"); + sb.Append("Host: ").Append(host).Append("\r\n"); + sb.Append("Connection: Upgrade\r\n"); + sb.Append("Upgrade: tcp\r\n"); + sb.Append("Accept: application/vnd.docker.raw-stream\r\n"); + if (body is { Length: > 0 }) { + sb.Append("Content-Type: application/json\r\n"); + sb.Append("Content-Length: ").Append(body.Length).Append("\r\n"); + } + sb.Append("\r\n"); + var headerBytes = Encoding.ASCII.GetBytes(sb.ToString()); + if (body is null or { Length: 0 }) + return headerBytes; + + var combined = new byte[headerBytes.Length + body.Length]; + headerBytes.CopyTo(combined, 0); + body.CopyTo(combined, headerBytes.Length); + return combined; + } + + private static async Task ReadResponseHeadersAsync(Stream stream, CancellationToken cancellationToken) { + var statusLine = await ReadAsciiLineAsync(stream, cancellationToken).ConfigureAwait(false) + ?? throw new IOException("Empty response from Podman hijack endpoint."); + + if (!statusLine.Contains(" 200 ", StringComparison.Ordinal) && !statusLine.Contains(" 101 ", StringComparison.Ordinal)) + throw new IOException($"Podman hijack failed: {statusLine}"); + + while (true) { + var line = await ReadAsciiLineAsync(stream, cancellationToken).ConfigureAwait(false); + if (line is null || line.Length == 0) + break; + } + } + + private static async Task ReadAsciiLineAsync(Stream stream, CancellationToken cancellationToken) { + var buffer = new StringBuilder(); + while (true) { + var single = new byte[1]; + var read = await stream.ReadAsync(single, cancellationToken).ConfigureAwait(false); + if (read == 0) + return buffer.Length == 0 ? null : buffer.ToString(); + + var b = single[0]; + if (b == '\n') { + if (buffer.Length > 0 && buffer[^1] == '\r') + buffer.Length--; + return buffer.ToString(); + } + + buffer.Append((char)b); + } + } +} diff --git a/src/PodmanClient/Internal/PodmanHijackStream.cs b/src/PodmanClient/Internal/PodmanHijackStream.cs new file mode 100644 index 0000000..57a6f39 --- /dev/null +++ b/src/PodmanClient/Internal/PodmanHijackStream.cs @@ -0,0 +1,53 @@ +using System.Net.Sockets; + +namespace MaksIT.PodmanClientDotNet.Internal; + +/// +/// Duplex network stream returned from a hijacked Podman HTTP connection. +/// +internal sealed class PodmanHijackStream : Stream { + private readonly Stream _inner; + private readonly TcpClient? _tcpClient; + + public PodmanHijackStream(Stream inner, TcpClient? tcpClient = null) { + _inner = inner; + _tcpClient = tcpClient; + } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => _inner.CanWrite; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public void CloseWrite() { + if (_tcpClient?.Client.Connected == true) + _tcpClient.Client.Shutdown(SocketShutdown.Send); + } + + public override void Flush() => _inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + _inner.ReadAsync(buffer, offset, count, cancellationToken); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + _inner.ReadAsync(buffer, cancellationToken); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + _inner.WriteAsync(buffer, offset, count, cancellationToken); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => + _inner.WriteAsync(buffer, cancellationToken); + + protected override void Dispose(bool disposing) { + if (disposing) { + _inner.Dispose(); + _tcpClient?.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/src/PodmanClient/Internal/PodmanHttpResults.cs b/src/PodmanClient/Internal/PodmanHttpResults.cs new file mode 100644 index 0000000..3571070 --- /dev/null +++ b/src/PodmanClient/Internal/PodmanHttpResults.cs @@ -0,0 +1,81 @@ +using System.Net; + +using Microsoft.Extensions.Logging; + +using MaksIT.Core.Extensions; +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.Results; + +namespace MaksIT.PodmanClientDotNet.Internal; + +internal static class PodmanHttpResults { + public static string GetErrorMessage(string content) { + if (string.IsNullOrWhiteSpace(content)) + return "Podman API request failed."; + + var error = content.ToObject(); + return string.IsNullOrWhiteSpace(error?.Message) ? content : error.Message; + } + + public static Result Success(HttpStatusCode statusCode, T? value, params string[] messages) => + statusCode switch { + HttpStatusCode.Created => Result.Created(value, messages), + HttpStatusCode.NoContent => Result.NoContent(value, messages), + HttpStatusCode.Accepted => Result.Accepted(value, messages), + _ => Result.Ok(value, messages), + }; + + public static Result Success(HttpStatusCode statusCode, params string[] messages) => + statusCode switch { + HttpStatusCode.Created => Result.Created(messages), + HttpStatusCode.NoContent => Result.NoContent(messages), + HttpStatusCode.Accepted => Result.Accepted(messages), + _ => Result.Ok(messages), + }; + + public static Result Failure(HttpStatusCode statusCode, string message) => + statusCode switch { + HttpStatusCode.BadRequest => Result.BadRequest(default, message), + HttpStatusCode.Unauthorized => Result.Unauthorized(default, message), + HttpStatusCode.Forbidden => Result.Forbidden(default, message), + HttpStatusCode.NotFound => Result.NotFound(default, message), + HttpStatusCode.Conflict => Result.Conflict(default, message), + HttpStatusCode.InternalServerError => Result.InternalServerError(default, message), + HttpStatusCode.ServiceUnavailable => Result.ServiceUnavailable(default, message), + HttpStatusCode.GatewayTimeout => Result.GatewayTimeout(default, message), + _ when (int)statusCode >= 500 => Result.InternalServerError(default, message), + _ => Result.BadRequest(default, message), + }; + + public static Result Failure(HttpStatusCode statusCode, string message) => + statusCode switch { + HttpStatusCode.BadRequest => Result.BadRequest(message), + HttpStatusCode.Unauthorized => Result.Unauthorized(message), + HttpStatusCode.Forbidden => Result.Forbidden(message), + HttpStatusCode.NotFound => Result.NotFound(message), + HttpStatusCode.Conflict => Result.Conflict(message), + HttpStatusCode.InternalServerError => Result.InternalServerError(message), + HttpStatusCode.ServiceUnavailable => Result.ServiceUnavailable(message), + HttpStatusCode.GatewayTimeout => Result.GatewayTimeout(message), + _ when (int)statusCode >= 500 => Result.InternalServerError(message), + _ => Result.BadRequest(message), + }; + + public static void LogFailure(ILogger logger, HttpStatusCode statusCode, string operation, string message) { + switch (statusCode) { + case HttpStatusCode.NotFound: + logger.LogWarning("{Operation} failed: {Message}", operation, message); + break; + case HttpStatusCode.Conflict: + case HttpStatusCode.NotModified: + logger.LogWarning("{Operation}: {Message}", operation, message); + break; + default: + if ((int)statusCode >= 500) + logger.LogError("{Operation} failed: {Message}", operation, message); + else + logger.LogError("{Operation} failed ({StatusCode}): {Message}", operation, (int)statusCode, message); + break; + } + } +} diff --git a/src/PodmanClient/Internal/PodmanNdjsonStreams.cs b/src/PodmanClient/Internal/PodmanNdjsonStreams.cs new file mode 100644 index 0000000..eed2592 --- /dev/null +++ b/src/PodmanClient/Internal/PodmanNdjsonStreams.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Logging; + +using MaksIT.Core.Extensions; +using MaksIT.PodmanClientDotNet.Dtos.Build; +using MaksIT.PodmanClientDotNet.Dtos.Image; +using MaksIT.Results; + +namespace MaksIT.PodmanClientDotNet.Internal; + +internal static class PodmanNdjsonStreams { + public static async Task DrainPullOrPushAsync( + Stream stream, + ILogger logger, + string operation, + CancellationToken cancellationToken = default + ) { + await using var owned = stream; + using var reader = new StreamReader(stream, leaveOpen: false); + + while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) { + if (string.IsNullOrWhiteSpace(line)) + continue; + + if (!line.Contains("\"error\"", StringComparison.Ordinal)) + continue; + + var errorDetails = line.ToObject(); + var message = errorDetails?.Error ?? $"{operation} failed."; + logger.LogError("{Operation} failed: {Message}", operation, message); + return Result.BadRequest(message); + } + + return Result.Ok($"{operation} completed successfully."); + } + + public static async Task> DrainBuildAsync( + Stream stream, + ILogger logger, + CancellationToken cancellationToken = default + ) { + await using var owned = stream; + using var reader = new StreamReader(stream, leaveOpen: false); + BuildReportDto? report = null; + + while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) { + if (string.IsNullOrWhiteSpace(line)) + continue; + + var progress = line.ToObject(); + if (progress is null) + continue; + + if (!string.IsNullOrWhiteSpace(progress.Error)) { + logger.LogError("Build image failed: {Message}", progress.Error); + return Result.BadRequest(null, progress.Error); + } + + if (!string.IsNullOrWhiteSpace(progress.Id)) + report = new BuildReportDto { Id = progress.Id, Names = report?.Names }; + } + + return Result.Ok(report, "Image built successfully."); + } +} diff --git a/src/PodmanClient/Internal/PodmanOwnedResponseStream.cs b/src/PodmanClient/Internal/PodmanOwnedResponseStream.cs new file mode 100644 index 0000000..3e70817 --- /dev/null +++ b/src/PodmanClient/Internal/PodmanOwnedResponseStream.cs @@ -0,0 +1,51 @@ +namespace MaksIT.PodmanClientDotNet.Internal; + +/// +/// Wraps a response stream and disposes the underlying when done. +/// +internal sealed class PodmanOwnedResponseStream : Stream { + private readonly Stream _inner; + private readonly HttpResponseMessage _response; + private bool _disposed; + + public PodmanOwnedResponseStream(Stream inner, HttpResponseMessage response) { + _inner = inner; + _response = response; + } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => _inner.CanSeek; + public override bool CanWrite => _inner.CanWrite; + public override long Length => _inner.Length; + public override long Position { get => _inner.Position; set => _inner.Position = value; } + + public override void Flush() => _inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); + public override void SetLength(long value) => _inner.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + _inner.ReadAsync(buffer, offset, count, cancellationToken); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + _inner.ReadAsync(buffer, cancellationToken); + + protected override void Dispose(bool disposing) { + if (!_disposed && disposing) { + _inner.Dispose(); + _response.Dispose(); + _disposed = true; + } + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() { + if (!_disposed) { + await _inner.DisposeAsync().ConfigureAwait(false); + _response.Dispose(); + _disposed = true; + } + await base.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/PodmanClient/Models/AutoUserNsOptions.cs b/src/PodmanClient/Models/AutoUserNsOptions.cs index ab93641..6cfdbd1 100644 --- a/src/PodmanClient/Models/AutoUserNsOptions.cs +++ b/src/PodmanClient/Models/AutoUserNsOptions.cs @@ -1,12 +1,15 @@ - -namespace MaksIT.PodmanClientDotNet.Models { - public class AutoUserNsOptions { - public List AdditionalGIDMappings { get; set; } - public List AdditionalUIDMappings { get; set; } - public string GroupFile { get; set; } - public int InitialSize { get; set; } - public string PasswdFile { get; set; } - public int Size { get; set; } - } -} +namespace MaksIT.PodmanClientDotNet.Models; + +/// +/// Libpod container or image specification model (Auto User Ns Options). +/// + +public class AutoUserNsOptions { + public List? AdditionalGIDMappings { get; set; } + public List? AdditionalUIDMappings { get; set; } + public string? GroupFile { get; set; } + 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 5dfa213..bb37c0c 100644 --- a/src/PodmanClient/Models/BindOptions.cs +++ b/src/PodmanClient/Models/BindOptions.cs @@ -1,16 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class BindOptions { - public bool CreateMountpoint { get; set; } - public bool NonRecursive { get; set; } - public string Propagation { get; set; } - public bool ReadOnlyForceRecursive { get; set; } - public bool ReadOnlyNonRecursive { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Bind Options). +/// + +public class BindOptions { + public bool CreateMountpoint { get; set; } + public bool NonRecursive { get; set; } + 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 3f211be..4e0ff31 100644 --- a/src/PodmanClient/Models/BlockIO.cs +++ b/src/PodmanClient/Models/BlockIO.cs @@ -1,18 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class BlockIO { - public int LeafWeight { get; set; } - public List ThrottleReadBpsDevice { get; set; } - public List ThrottleReadIopsDevice { get; set; } - public List ThrottleWriteBpsDevice { get; set; } - public List ThrottleWriteIopsDevice { get; set; } - public int Weight { get; set; } - public List WeightDevice { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Block I O). +/// + +public class BlockIO { + public int LeafWeight { get; set; } + public List? ThrottleReadBpsDevice { get; set; } + public List? ThrottleReadIopsDevice { get; set; } + public List? ThrottleWriteBpsDevice { get; set; } + 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 79e2757..c73a63e 100644 --- a/src/PodmanClient/Models/CPU.cs +++ b/src/PodmanClient/Models/CPU.cs @@ -1,20 +1,18 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class CPU { - public int Burst { get; set; } - public string Cpus { get; set; } - public int Idle { get; set; } - public string Mems { get; set; } - public int Period { get; set; } - public int Quota { get; set; } - public int RealtimePeriod { get; set; } - public int RealtimeRuntime { get; set; } - public int Shares { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (C P U). +/// + +public class CPU { + public int Burst { get; set; } + public string? Cpus { get; set; } + public int Idle { get; set; } + public string? Mems { get; set; } + public int Period { get; set; } + public int Quota { get; set; } + 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 d9a09cf..99f14dc 100644 --- a/src/PodmanClient/Models/Container/CreateContainerRequest.cs +++ b/src/PodmanClient/Models/Container/CreateContainerRequest.cs @@ -1,124 +1,127 @@ - -namespace MaksIT.PodmanClientDotNet.Models.Container { - public class CreateContainerRequest { - public Dictionary Annotations { get; set; } - public string ApparmorProfile { get; set; } - public string BaseHostsFile { get; set; } - public List CapAdd { get; set; } - public List CapDrop { get; set; } - public string CgroupParent { get; set; } - public Namespace Cgroupns { get; set; } - public string CgroupsMode { get; set; } - public List ChrootDirectories { get; set; } - public List CNINetworks { get; set; } - public List Command { get; set; } - public string ConmonPidFile { get; set; } - public List ContainerCreateCommand { get; set; } - public bool? CreateWorkingDir { get; set; } - public List DependencyContainers { get; set; } - public List DeviceCgroupRule { get; set; } - public List Devices { get; set; } - public List DevicesFrom { get; set; } - public List DNSOption { get; set; } - public List DNSSearch { get; set; } - public List DNSServer { get; set; } - public List Entrypoint { get; set; } - public Dictionary Env { get; set; } - public bool? EnvHost { get; set; } - public List EnvMerge { get; set; } - public Dictionary Expose { get; set; } - public string GroupEntry { get; set; } - public List Groups { get; set; } - public long? HealthCheckOnFailureAction { get; set; } - public Schema2HealthConfig HealthConfig { get; set; } - public List HostDeviceList { get; set; } - public List HostAdd { get; set; } - public string Hostname { get; set; } - public List HostUsers { get; set; } - public bool? EnvHTTPProxy { get; set; } - public IDMappingOptions IDMappings { get; set; } - public string Image { get; set; } - public string ImageArch { get; set; } - public string ImageOS { get; set; } - public string ImageVariant { get; set; } - public string ImageVolumeMode { get; set; } - public List ImageVolumes { get; set; } - public bool? Init { get; set; } - public string InitContainerType { get; set; } - public string InitPath { get; set; } - public LinuxIntelRdt IntelRdt { get; set; } - public Namespace Ipcns { get; set; } - public bool? LabelNested { get; set; } - public Dictionary Labels { get; set; } - public LogConfigLibpod LogConfiguration { get; set; } - public bool? ManagePassword { get; set; } - public List Mask { get; set; } - public List Mounts { get; set; } - public string Name { get; set; } - public Namespace Netns { get; set; } - public Dictionary NetworkOptions { get; set; } - public Dictionary Networks { get; set; } - public bool? NoNewPrivileges { get; set; } - public string OciRuntime { get; set; } - public long? OomScoreAdj { get; set; } - public List OverlayVolumes { get; set; } - public string PasswdEntry { get; set; } - public LinuxPersonality Personality { get; set; } - public Namespace Pidns { get; set; } - public string Pod { get; set; } - public List Portmappings { get; set; } - public bool? Privileged { get; set; } - public List ProcfsOpts { get; set; } - public bool? PublishImagePorts { get; set; } - public List RLimits { get; set; } - public string RawImageName { get; set; } - public bool? ReadOnlyFilesystem { get; set; } - public bool? ReadWriteTmpfs { get; set; } - public bool? Remove { get; set; } - public LinuxResources ResourceLimits { get; set; } - public string RestartPolicy { get; set; } - public ulong? RestartTries { get; set; } - public string Rootfs { get; set; } - public string RootfsMapping { get; set; } - public bool? RootfsOverlay { get; set; } - public string RootfsPropagation { get; set; } - public string SdnotifyMode { get; set; } - public string SeccompPolicy { get; set; } - public string SeccompProfilePath { get; set; } - public Dictionary SecretEnv { get; set; } - public List Secrets { get; set; } - public List SelinuxOpts { get; set; } - public long? ShmSize { get; set; } - public long? ShmSizeSystemd { get; set; } - public StartupHealthConfig StartupHealthConfig { get; set; } - public bool? Stdin { get; set; } - public long? StopSignal { get; set; } - public ulong? StopTimeout { get; set; } - public Dictionary StorageOpts { get; set; } - public Dictionary Sysctl { get; set; } - public string Systemd { get; set; } - public bool? Terminal { get; set; } - public Dictionary ThrottleReadBpsDevice { get; set; } - public Dictionary ThrottleReadIopsDevice { get; set; } - public Dictionary ThrottleWriteBpsDevice { get; set; } - public Dictionary ThrottleWriteIopsDevice { get; set; } - public ulong? Timeout { get; set; } - public string Timezone { get; set; } - public string Umask { get; set; } - public Dictionary Unified { get; set; } - public List Unmask { get; set; } - public List Unsetenv { get; set; } - public bool? Unsetenvall { get; set; } - public bool? UseImageHosts { get; set; } - public bool? UseImageResolvConf { get; set; } - public string User { get; set; } - public Namespace Userns { get; set; } - public Namespace Utsns { get; set; } - public bool? Volatile { get; set; } - public List Volumes { get; set; } - public List VolumesFrom { get; set; } - public Dictionary WeightDevice { get; set; } - public string WorkDir { get; set; } - } -} +namespace MaksIT.PodmanClientDotNet.Models.Container; + +/// +/// Libpod API request body for Create Container request. +/// + +public class CreateContainerRequest { + public Dictionary? Annotations { get; set; } + public string? ApparmorProfile { get; set; } + public string? BaseHostsFile { get; set; } + public List? CapAdd { get; set; } + public List? CapDrop { get; set; } + public string? CgroupParent { get; set; } + public Namespace? Cgroupns { get; set; } + public string? CgroupsMode { get; set; } + public List? ChrootDirectories { get; set; } + public List? CNINetworks { get; set; } + public List? Command { get; set; } + public string? ConmonPidFile { get; set; } + public List? ContainerCreateCommand { get; set; } + public bool? CreateWorkingDir { get; set; } + public List? DependencyContainers { get; set; } + public List? DeviceCgroupRule { get; set; } + public List? Devices { get; set; } + public List? DevicesFrom { get; set; } + public List? DNSOption { get; set; } + public List? DNSSearch { get; set; } + public List? DNSServer { get; set; } + public List? Entrypoint { get; set; } + public Dictionary? Env { get; set; } + public bool? EnvHost { get; set; } + public List? EnvMerge { get; set; } + public Dictionary? Expose { get; set; } + public string? GroupEntry { get; set; } + public List? Groups { get; set; } + public long? HealthCheckOnFailureAction { get; set; } + public Schema2HealthConfig? HealthConfig { get; set; } + public List? HostDeviceList { get; set; } + public List? HostAdd { get; set; } + public string? Hostname { get; set; } + public List? HostUsers { get; set; } + public bool? EnvHTTPProxy { get; set; } + public IDMappingOptions? IDMappings { get; set; } + public string? Image { get; set; } + public string? ImageArch { get; set; } + public string? ImageOS { get; set; } + public string? ImageVariant { get; set; } + public string? ImageVolumeMode { get; set; } + public List? ImageVolumes { get; set; } + public bool? Init { get; set; } + public string? InitContainerType { get; set; } + public string? InitPath { get; set; } + public LinuxIntelRdt? IntelRdt { get; set; } + public Namespace? Ipcns { get; set; } + public bool? LabelNested { get; set; } + public Dictionary? Labels { get; set; } + public LogConfigLibpod? LogConfiguration { get; set; } + public bool? ManagePassword { get; set; } + public List? Mask { get; set; } + public List? Mounts { get; set; } + public string? Name { get; set; } + public Namespace? Netns { get; set; } + public Dictionary? NetworkOptions { get; set; } + public Dictionary? Networks { get; set; } + public bool? NoNewPrivileges { get; set; } + public string? OciRuntime { get; set; } + public long? OomScoreAdj { get; set; } + public List? OverlayVolumes { get; set; } + public string? PasswdEntry { get; set; } + public LinuxPersonality? Personality { get; set; } + public Namespace? Pidns { get; set; } + public string? Pod { get; set; } + public List? Portmappings { get; set; } + public bool? Privileged { get; set; } + public List? ProcfsOpts { get; set; } + public bool? PublishImagePorts { get; set; } + public List? RLimits { get; set; } + public string? RawImageName { get; set; } + public bool? ReadOnlyFilesystem { get; set; } + public bool? ReadWriteTmpfs { get; set; } + public bool? Remove { get; set; } + public LinuxResources? ResourceLimits { get; set; } + public string? RestartPolicy { get; set; } + public ulong? RestartTries { get; set; } + public string? Rootfs { get; set; } + public string? RootfsMapping { get; set; } + public bool? RootfsOverlay { get; set; } + public string? RootfsPropagation { get; set; } + public string? SdnotifyMode { get; set; } + public string? SeccompPolicy { get; set; } + public string? SeccompProfilePath { get; set; } + public Dictionary? SecretEnv { get; set; } + public List? Secrets { get; set; } + public List? SelinuxOpts { get; set; } + public long? ShmSize { get; set; } + public long? ShmSizeSystemd { get; set; } + public StartupHealthConfig? StartupHealthConfig { get; set; } + public bool? Stdin { get; set; } + public long? StopSignal { get; set; } + public ulong? StopTimeout { get; set; } + public Dictionary? StorageOpts { get; set; } + public Dictionary? Sysctl { get; set; } + public string? Systemd { get; set; } + public bool? Terminal { get; set; } + public Dictionary? ThrottleReadBpsDevice { get; set; } + public Dictionary? ThrottleReadIopsDevice { get; set; } + public Dictionary? ThrottleWriteBpsDevice { get; set; } + public Dictionary? ThrottleWriteIopsDevice { get; set; } + public ulong? Timeout { get; set; } + public string? Timezone { get; set; } + public string? Umask { get; set; } + public Dictionary? Unified { get; set; } + public List? Unmask { get; set; } + public List? Unsetenv { get; set; } + public bool? Unsetenvall { get; set; } + public bool? UseImageHosts { get; set; } + public bool? UseImageResolvConf { get; set; } + public string? User { get; set; } + public Namespace? Userns { get; set; } + public Namespace? Utsns { get; set; } + public bool? Volatile { get; set; } + public List? Volumes { get; set; } + 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 6842ccf..2dfecba 100644 --- a/src/PodmanClient/Models/Container/CreateContainerResponse.cs +++ b/src/PodmanClient/Models/Container/CreateContainerResponse.cs @@ -1,13 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models.Container { - public class CreateContainerResponse { - public string Id { get; set; } +namespace MaksIT.PodmanClientDotNet.Models.Container; - public string[] Warnings { get; set; } - } -} +/// +/// Libpod API response body for Create Container response. +/// + +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 962f4ee..59b5895 100644 --- a/src/PodmanClient/Models/Container/DeleteContainerResponse.cs +++ b/src/PodmanClient/Models/Container/DeleteContainerResponse.cs @@ -1,12 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models.Container { - public class DeleteContainerResponse { - public string Err { get; set; } - public string Id { get; set; } - } -} +namespace MaksIT.PodmanClientDotNet.Models.Container; + +/// +/// Libpod API response body for Delete Container response. +/// + +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 06ffb10..2f285ee 100644 --- a/src/PodmanClient/Models/DriverConfig.cs +++ b/src/PodmanClient/Models/DriverConfig.cs @@ -1,13 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class DriverConfig { - public string Name { get; set; } - public Dictionary Options { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Driver Config). +/// + +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 9abda48..71e7466 100644 --- a/src/PodmanClient/Models/ErrorResponse.cs +++ b/src/PodmanClient/Models/ErrorResponse.cs @@ -1,13 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class ErrorResponse { - public string Cause { get; set; } - public string Message { get; set; } - public int Response { get; set; } - } -} +namespace MaksIT.PodmanClientDotNet.Models; + +/// +/// Libpod API response body for Error response. +/// + +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 c445061..6cb3408 100644 --- a/src/PodmanClient/Models/Exec/CreateExecRequest.cs +++ b/src/PodmanClient/Models/Exec/CreateExecRequest.cs @@ -1,22 +1,19 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models.Exec -{ - public class CreateExecRequest - { - public bool AttachStderr { get; set; } - public bool AttachStdin { get; set; } - public bool AttachStdout { get; set; } - public string[] Cmd { get; set; } - public string DetachKeys { get; set; } - public string[] Env { get; set; } - public bool Privileged { get; set; } - public bool Tty { get; set; } - public string User { get; set; } - public string WorkingDir { get; set; } - } -} +namespace MaksIT.PodmanClientDotNet.Models.Exec; + +/// +/// Libpod API request body for Create Exec request. +/// + +public class CreateExecRequest { + public bool AttachStderr { get; set; } + public bool AttachStdin { get; set; } + public bool AttachStdout { get; set; } + public string[]? Cmd { get; set; } + public string? DetachKeys { get; set; } + public string[]? Env { get; set; } + public bool Privileged { get; set; } + 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 c28c8e7..4cd1846 100644 --- a/src/PodmanClient/Models/Exec/CreateExecResponse.cs +++ b/src/PodmanClient/Models/Exec/CreateExecResponse.cs @@ -1,12 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models.Exec { - public class CreateExecResponse { - public string Id { get; set; } // The ID of the exec instance - } +namespace MaksIT.PodmanClientDotNet.Models.Exec; -} +/// +/// Libpod API response body for Create Exec response. +/// + +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 d9eb876..35c9843 100644 --- a/src/PodmanClient/Models/Exec/InspectExecResponse.cs +++ b/src/PodmanClient/Models/Exec/InspectExecResponse.cs @@ -1,13 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models.Exec { - public class InspectExecResponse { - public bool Running { get; set; } - public int ExitCode { get; set; } - public string ProcessConfig { get; set; } // Additional fields can be added based on your needs - } -} +namespace MaksIT.PodmanClientDotNet.Models.Exec; + +/// +/// Libpod API response body for Inspect Exec response. +/// + +public class InspectExecResponse { + public bool Running { get; set; } + public int ExitCode { get; set; } + public string? ProcessConfig { get; set; } +} \ No newline at end of file diff --git a/src/PodmanClient/Models/Exec/StartExecRequest.cs b/src/PodmanClient/Models/Exec/StartExecRequest.cs index 0dacefe..d6aef30 100644 --- a/src/PodmanClient/Models/Exec/StartExecRequest.cs +++ b/src/PodmanClient/Models/Exec/StartExecRequest.cs @@ -1,14 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models.Exec { - public class StartExecRequest { - public bool Detach { get; set; } - 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 - } -} +namespace MaksIT.PodmanClientDotNet.Models.Exec; + +/// +/// Libpod API request body for Start Exec request. +/// + +public class StartExecRequest { + public bool Detach { get; set; } + 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 a1f748a..2ad99f2 100644 --- a/src/PodmanClient/Models/HugepageLimit.cs +++ b/src/PodmanClient/Models/HugepageLimit.cs @@ -1,12 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class HugepageLimit { - public long Limit { get; set; } - public string PageSize { get; set; } - } -} +namespace MaksIT.PodmanClientDotNet.Models; + +/// +/// Libpod container or image specification model (Hugepage Limit). +/// + +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 25107da..6d8d742 100644 --- a/src/PodmanClient/Models/IDMapping.cs +++ b/src/PodmanClient/Models/IDMapping.cs @@ -1,14 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class IDMapping { - public int ContainerId { get; set; } - public int HostId { get; set; } - public int Size { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (I D Mapping). +/// + +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 d640ac6..ca2145f 100644 --- a/src/PodmanClient/Models/IDMappingOptions.cs +++ b/src/PodmanClient/Models/IDMappingOptions.cs @@ -1,18 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class IDMappingOptions { - public bool AutoUserNs { get; set; } - public AutoUserNsOptions AutoUserNsOpts { get; set; } - public List GIDMap { get; set; } - public bool HostGIDMapping { get; set; } - public bool HostUIDMapping { get; set; } - public List UIDMap { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (I D Mapping Options). +/// + +public class IDMappingOptions { + public bool AutoUserNs { get; set; } + public AutoUserNsOptions? AutoUserNsOpts { get; set; } + public List? GIDMap { get; set; } + 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 576ac8e..d0615cc 100644 --- a/src/PodmanClient/Models/Image/PullImageResponse.cs +++ b/src/PodmanClient/Models/Image/PullImageResponse.cs @@ -1,32 +1,26 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models.Image -{ - public class PullImageResponse +namespace MaksIT.PodmanClientDotNet.Models.Image; + +public class PullImageResponse { /// /// Error contains text of errors from c/image. /// - public string Error { get; set; } + public string? Error { get; set; } /// /// ID contains image ID (retained for backwards compatibility). /// - public string Id { get; set; } + public string? Id { get; set; } /// /// Images contains the IDs of the images pulled. /// - public List Images { get; set; } + public List? Images { get; set; } /// /// Stream used to provide output from c/image. /// - public string Stream { get; set; } + public string? Stream { get; set; } } -} diff --git a/src/PodmanClient/Models/ImageVolume.cs b/src/PodmanClient/Models/ImageVolume.cs index eb9d0c6..39027ed 100644 --- a/src/PodmanClient/Models/ImageVolume.cs +++ b/src/PodmanClient/Models/ImageVolume.cs @@ -1,15 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class ImageVolume { - public string Destination { get; set; } - public bool ReadWrite { get; set; } - public string Source { get; set; } - public string SubPath { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Image Volume). +/// + +public class ImageVolume { + public string? Destination { get; set; } + 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 fcb9ff0..90b7658 100644 --- a/src/PodmanClient/Models/IntelRdt.cs +++ b/src/PodmanClient/Models/IntelRdt.cs @@ -1,16 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class IntelRdt { - public string ClosId { get; set; } - public bool EnableCMT { get; set; } - public bool EnableMBM { get; set; } - public string L3CacheSchema { get; set; } - public string MemBwSchema { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Intel Rdt). +/// + +public class IntelRdt { + public string? ClosId { get; set; } + public bool EnableCMT { get; set; } + 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 86e40c5..a844485 100644 --- a/src/PodmanClient/Models/LinuxDevice.cs +++ b/src/PodmanClient/Models/LinuxDevice.cs @@ -1,18 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class LinuxDevice { - public int FileMode { get; set; } - public int Gid { get; set; } - public int Major { get; set; } - public int Minor { get; set; } - public string Path { get; set; } - public string Type { get; set; } - public int Uid { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Linux Device). +/// + +public class LinuxDevice { + public int FileMode { get; set; } + public int Gid { get; set; } + public int Major { get; set; } + public int Minor { get; set; } + 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 14b44bd..9410c46 100644 --- a/src/PodmanClient/Models/LinuxDeviceCgroup.cs +++ b/src/PodmanClient/Models/LinuxDeviceCgroup.cs @@ -1,16 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class LinuxDeviceCgroup { - public string Access { get; set; } - public bool Allow { get; set; } - public int Major { get; set; } - public int Minor { get; set; } - public string Type { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Linux Device Cgroup). +/// + +public class LinuxDeviceCgroup { + public string? Access { get; set; } + public bool Allow { get; set; } + 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 b5652bc..2ffa3ef 100644 --- a/src/PodmanClient/Models/LinuxIntelRdt.cs +++ b/src/PodmanClient/Models/LinuxIntelRdt.cs @@ -1,16 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class LinuxIntelRdt { - public string ClosID { get; set; } - public bool EnableCMT { get; set; } - public bool EnableMBM { get; set; } - public string L3CacheSchema { get; set; } - public string MemBwSchema { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Linux Intel Rdt). +/// + +public class LinuxIntelRdt { + public string? ClosID { get; set; } + public bool EnableCMT { get; set; } + 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 ebf4389..6a45c46 100644 --- a/src/PodmanClient/Models/LinuxPersonality.cs +++ b/src/PodmanClient/Models/LinuxPersonality.cs @@ -1,13 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class LinuxPersonality { - public string Domain { get; set; } - public List Flags { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Linux Personality). +/// + +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 c51daa1..a792451 100644 --- a/src/PodmanClient/Models/LinuxResources.cs +++ b/src/PodmanClient/Models/LinuxResources.cs @@ -1,21 +1,18 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class LinuxResources { - public BlockIO BlockIO { get; set; } - public CPU CPU { get; set; } - public List Devices { get; set; } - public List HugepageLimits { get; set; } - public Memory Memory { get; set; } - public Network Network { get; set; } - public Pids Pids { get; set; } - public Dictionary Rdma { get; set; } - public Dictionary Unified { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Linux Resources). +/// + +public class LinuxResources { + public BlockIO? BlockIO { get; set; } + public CPU? CPU { get; set; } + public List? Devices { get; set; } + public List? HugepageLimits { get; set; } + public Memory? Memory { get; set; } + public LinuxNetwork? Network { get; set; } + 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 4ebdfbc..3242137 100644 --- a/src/PodmanClient/Models/LogConfigLibpod.cs +++ b/src/PodmanClient/Models/LogConfigLibpod.cs @@ -1,15 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class LogConfigLibpod { - public string Driver { get; set; } - public Dictionary Options { get; set; } - public string Path { get; set; } - public long Size { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Log Config Libpod). +/// + +public class LogConfigLibpod { + public string? Driver { get; set; } + 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 e152041..d790997 100644 --- a/src/PodmanClient/Models/Memory.cs +++ b/src/PodmanClient/Models/Memory.cs @@ -1,20 +1,18 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class Memory { - public bool CheckBeforeUpdate { get; set; } - public bool DisableOOMKiller { get; set; } - public long Kernel { get; set; } - public long KernelTCP { get; set; } - public long Limit { get; set; } - public long Reservation { get; set; } - public long Swap { get; set; } - public int Swappiness { get; set; } - public bool UseHierarchy { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Memory). +/// + +public class Memory { + public bool CheckBeforeUpdate { get; set; } + public bool DisableOOMKiller { get; set; } + public long Kernel { get; set; } + public long KernelTCP { get; set; } + public long Limit { get; set; } + public long Reservation { get; set; } + 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 ce78199..48a6858 100644 --- a/src/PodmanClient/Models/Mount.cs +++ b/src/PodmanClient/Models/Mount.cs @@ -1,20 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.NetworkInformation; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class Mount { - public BindOptions BindOptions { get; set; } - public string Consistency { get; set; } - public bool ReadOnly { get; set; } - public string Source { get; set; } - public string Target { get; set; } - public TmpfsOptions TmpfsOptions { get; set; } - public string Type { get; set; } - public VolumeOptions VolumeOptions { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Mount). +/// + +public class Mount { + public BindOptions? BindOptions { get; set; } + public string? Consistency { get; set; } + public bool ReadOnly { get; set; } + public string? Source { get; set; } + public string? Target { get; set; } + 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 91835f6..95e8a55 100644 --- a/src/PodmanClient/Models/NamedVolume.cs +++ b/src/PodmanClient/Models/NamedVolume.cs @@ -1,16 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class NamedVolume { - public string Dest { get; set; } - public bool IsAnonymous { get; set; } - public string Name { get; set; } - public List Options { get; set; } - public string SubPath { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Named Volume). +/// + +public class NamedVolume { + public string? Dest { get; set; } + public bool IsAnonymous { get; set; } + 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 1b038db..113cbd6 100644 --- a/src/PodmanClient/Models/Namespace.cs +++ b/src/PodmanClient/Models/Namespace.cs @@ -1,13 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class Namespace { - public string Nsmode { get; set; } - public string Value { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Namespace). +/// + +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/Network.cs b/src/PodmanClient/Models/Network.cs index a631ff9..4c2ec65 100644 --- a/src/PodmanClient/Models/Network.cs +++ b/src/PodmanClient/Models/Network.cs @@ -1,13 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class Network { - public int ClassID { get; set; } - public List Priorities { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; +/// +/// Linux network resource limits (class ID and priorities) for container creation. +/// +public class LinuxNetwork { + public int ClassID { get; set; } + public List? Priorities { get; set; } } diff --git a/src/PodmanClient/Models/Network/NetworkModels.cs b/src/PodmanClient/Models/Network/NetworkModels.cs new file mode 100644 index 0000000..592e551 --- /dev/null +++ b/src/PodmanClient/Models/Network/NetworkModels.cs @@ -0,0 +1,40 @@ +namespace MaksIT.PodmanClientDotNet.Models.Network; + +/// +/// Libpod API request body for Network Create request. +/// + +public sealed class NetworkCreateRequest { + public string? Name { get; set; } + public bool? DisableDNS { get; set; } + public string? Driver { get; set; } + public Dictionary? Labels { get; set; } + public Dictionary? Options { get; set; } + public bool? Internal { get; set; } + public List? IPAMDriverConfigs { get; set; } + public List? DNSServers { get; set; } + public List? DNSSearchDomains { get; set; } + public List? DNSOptions { get; set; } + public List? Subnets { get; set; } + public List? IPv6Subnets { get; set; } + public List? NetworkInterface { get; set; } + public List? NetworkID { get; set; } + public List? NetworkName { get; set; } + public List? OptionsList { get; set; } +} + +/// +/// Libpod API request body to connect a container to networks. +/// +public sealed class NetworkConnectRequest { + public string? Container { get; set; } + public List? Networks { get; set; } +} + +/// +/// Libpod API request body to disconnect a container from a network. +/// +public sealed class NetworkDisconnectRequest { + public string? Container { get; set; } + public bool? Force { get; set; } +} diff --git a/src/PodmanClient/Models/NetworkPriority.cs b/src/PodmanClient/Models/NetworkPriority.cs index 9724ede..557cd58 100644 --- a/src/PodmanClient/Models/NetworkPriority.cs +++ b/src/PodmanClient/Models/NetworkPriority.cs @@ -1,13 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class NetworkPriority { - public string Name { get; set; } - public int Priority { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Network Priority). +/// + +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 b8ae788..e9ffd3b 100644 --- a/src/PodmanClient/Models/NetworkSettings.cs +++ b/src/PodmanClient/Models/NetworkSettings.cs @@ -1,15 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class NetworkSettings { - public List Aliases { get; set; } - public string InterfaceName { get; set; } - public List StaticIps { get; set; } - public string StaticMac { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Network Settings). +/// + +public class NetworkSettings { + public List? Aliases { get; set; } + 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 34f9b45..68ccf73 100644 --- a/src/PodmanClient/Models/OverlayVolume.cs +++ b/src/PodmanClient/Models/OverlayVolume.cs @@ -1,14 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class OverlayVolume { - public string Destination { get; set; } - public List Options { get; set; } - public string Source { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Overlay Volume). +/// + +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 160d5a6..2fb14f8 100644 --- a/src/PodmanClient/Models/POSIXRlimit.cs +++ b/src/PodmanClient/Models/POSIXRlimit.cs @@ -1,14 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class POSIXRlimit { - public long Hard { get; set; } - public long Soft { get; set; } - public string Type { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (P O S I X Rlimit). +/// + +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 3b8eb3f..0b40003 100644 --- a/src/PodmanClient/Models/Pids.cs +++ b/src/PodmanClient/Models/Pids.cs @@ -1,12 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class Pids { - public int Limit { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Pids). +/// + +public class Pids { + public int Limit { get; set; } +} \ No newline at end of file diff --git a/src/PodmanClient/Models/Pod/PodModels.cs b/src/PodmanClient/Models/Pod/PodModels.cs new file mode 100644 index 0000000..145cd10 --- /dev/null +++ b/src/PodmanClient/Models/Pod/PodModels.cs @@ -0,0 +1,24 @@ +namespace MaksIT.PodmanClientDotNet.Models.Pod; + +/// +/// Libpod API request body for Pod Create request. +/// + +public sealed class PodCreateRequest { + public string? Name { get; set; } + public string? CgroupParent { get; set; } + public Dictionary? Labels { get; set; } + public string? RestartPolicy { get; set; } + public ulong? StopTimeout { get; set; } + public string? ShareIpc { get; set; } + public string? ShareNet { get; set; } + public string? SharePid { get; set; } + public string? ShareUts { get; set; } + public string? ShareUser { get; set; } + public string? Hostname { get; set; } + public List? DNSOption { get; set; } + public List? DNSSearch { get; set; } + public List? DNSServer { get; set; } + public List? Sysctl { get; set; } + public List? NetNsPath { get; set; } +} diff --git a/src/PodmanClient/Models/PortMapping.cs b/src/PodmanClient/Models/PortMapping.cs index a59c47a..74ff90c 100644 --- a/src/PodmanClient/Models/PortMapping.cs +++ b/src/PodmanClient/Models/PortMapping.cs @@ -1,16 +1,14 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class PortMapping { - public int ContainerPort { get; set; } - public string HostIp { get; set; } - public int HostPort { get; set; } - public string Protocol { get; set; } - public int Range { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Port Mapping). +/// + +public class PortMapping { + public int ContainerPort { get; set; } + public string? HostIp { get; set; } + 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 1947611..569f9a8 100644 --- a/src/PodmanClient/Models/ProgressDetail.cs +++ b/src/PodmanClient/Models/ProgressDetail.cs @@ -1,12 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class ProgressDetail { - public long? Current { get; set; } - public long? Total { get; set; } - } -} +namespace MaksIT.PodmanClientDotNet.Models; + +/// +/// Libpod container or image specification model (Progress Detail). +/// + +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 832c384..4f20d68 100644 --- a/src/PodmanClient/Models/RdmaResource.cs +++ b/src/PodmanClient/Models/RdmaResource.cs @@ -1,13 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class RdmaResource { - public int HcaHandles { get; set; } - public int HcaObjects { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Rdma Resource). +/// + +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 6fc2391..a90cd2c 100644 --- a/src/PodmanClient/Models/Schema2HealthConfig.cs +++ b/src/PodmanClient/Models/Schema2HealthConfig.cs @@ -1,17 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class Schema2HealthConfig { - public long Interval { get; set; } - public int Retries { get; set; } - public long StartInterval { get; set; } - public long StartPeriod { get; set; } - public List Test { get; set; } - public long Timeout { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Schema2 Health Config). +/// + +public class Schema2HealthConfig { + public long Interval { get; set; } + public int Retries { get; set; } + public long StartInterval { get; set; } + 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 4360fa4..f30d2ca 100644 --- a/src/PodmanClient/Models/SecretProp.cs +++ b/src/PodmanClient/Models/SecretProp.cs @@ -1,13 +1,11 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class SecretProp { - public string Key { get; set; } - public string Secret { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Secret Prop). +/// + +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 550d195..7a651b7 100644 --- a/src/PodmanClient/Models/StartupHealthConfig.cs +++ b/src/PodmanClient/Models/StartupHealthConfig.cs @@ -1,18 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class StartupHealthConfig { - public long Interval { get; set; } - public int Retries { get; set; } - public long StartInterval { get; set; } - public long StartPeriod { get; set; } - public int Successes { get; set; } - public List Test { get; set; } - public long Timeout { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Startup Health Config). +/// + +public class StartupHealthConfig { + public long Interval { get; set; } + public int Retries { get; set; } + public long StartInterval { get; set; } + public long StartPeriod { get; set; } + 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 757119c..09f799e 100644 --- a/src/PodmanClient/Models/ThrottleDevice.cs +++ b/src/PodmanClient/Models/ThrottleDevice.cs @@ -1,14 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class ThrottleDevice { - public int Major { get; set; } - public int Minor { get; set; } - public long Rate { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Throttle Device). +/// + +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 8b095d0..9fef650 100644 --- a/src/PodmanClient/Models/TmpfsOptions.cs +++ b/src/PodmanClient/Models/TmpfsOptions.cs @@ -1,14 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class TmpfsOptions { - public int Mode { get; set; } - public List Options { get; set; } - public long SizeBytes { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Tmpfs Options). +/// + +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/Volume/VolumeModels.cs b/src/PodmanClient/Models/Volume/VolumeModels.cs new file mode 100644 index 0000000..7c8a169 --- /dev/null +++ b/src/PodmanClient/Models/Volume/VolumeModels.cs @@ -0,0 +1,12 @@ +namespace MaksIT.PodmanClientDotNet.Models.Volume; + +/// +/// Libpod API request body for Create Volume request. +/// + +public sealed class CreateVolumeRequest { + public string? Name { get; set; } + public Dictionary? DriverOpts { get; set; } + public Dictionary? Labels { get; set; } + public string? Driver { get; set; } +} diff --git a/src/PodmanClient/Models/VolumeOptions.cs b/src/PodmanClient/Models/VolumeOptions.cs index 5683d1a..9058155 100644 --- a/src/PodmanClient/Models/VolumeOptions.cs +++ b/src/PodmanClient/Models/VolumeOptions.cs @@ -1,15 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class VolumeOptions { - public DriverConfig DriverConfig { get; set; } - public Dictionary Labels { get; set; } - public bool NoCopy { get; set; } - public string Subpath { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Volume Options). +/// + +public class VolumeOptions { + public DriverConfig? DriverConfig { get; set; } + 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 3997b37..2b07104 100644 --- a/src/PodmanClient/Models/WeightDevice.cs +++ b/src/PodmanClient/Models/WeightDevice.cs @@ -1,15 +1,13 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -namespace MaksIT.PodmanClientDotNet.Models { - public class WeightDevice { - public int LeafWeight { get; set; } - public int Major { get; set; } - public int Minor { get; set; } - public int Weight { get; set; } - } +namespace MaksIT.PodmanClientDotNet.Models; -} +/// +/// Libpod container or image specification model (Weight Device). +/// + +public class WeightDevice { + public int LeafWeight { get; set; } + 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.Build.cs b/src/PodmanClient/PodmanClient.Build.cs new file mode 100644 index 0000000..16ebd60 --- /dev/null +++ b/src/PodmanClient/PodmanClient.Build.cs @@ -0,0 +1,53 @@ +using System.Net.Http.Headers; + +using MaksIT.PodmanClientDotNet.Dtos.Build; +using MaksIT.PodmanClientDotNet.Internal; +using MaksIT.Results; + +public partial class PodmanClient { + public async Task> BuildImageAsync( + string dockerfile, + Stream? context = null, + bool pull = false, + bool rm = true, + bool forcerm = false, + bool nocache = false, + string? remote = null, + string? t = null, + string? platform = null, + string? buildargs = null, + string? labels = null, + CancellationToken cancellationToken = default + ) { + HttpContent? content = null; + if (context is not null) { + content = new StreamContent(context); + content.Headers.ContentType = new MediaTypeHeaderValue("application/x-tar"); + } + + var streamResult = await PostStreamAsync( + "/libpod/build", + "Build image", + content, + [ + ("dockerfile", dockerfile), + ("pull", pull.ToString().ToLowerInvariant()), + ("rm", rm.ToString().ToLowerInvariant()), + ("forcerm", forcerm.ToString().ToLowerInvariant()), + ("nocache", nocache.ToString().ToLowerInvariant()), + ("remote", remote), + ("t", t), + ("platform", platform), + ("buildargs", buildargs), + ("labels", labels), + ], + cancellationToken: cancellationToken + ).ConfigureAwait(false); + + if (!streamResult.IsSuccess) + return streamResult.ToResultOfType(null!); + + return await PodmanNdjsonStreams.DrainBuildAsync(streamResult.Value!, _logger, cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/src/PodmanClient/PodmanClient.Container.cs b/src/PodmanClient/PodmanClient.Container.cs new file mode 100644 index 0000000..6da7157 --- /dev/null +++ b/src/PodmanClient/PodmanClient.Container.cs @@ -0,0 +1,367 @@ +using System.Net; + +using Microsoft.Extensions.Logging; + +using MaksIT.Core.Extensions; +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; + +public partial class PodmanClient { + public async Task> CreateContainerAsync( + string name, + string image, + List? command = null, + Dictionary? env = null, + bool? remove = null, + bool? stdin = null, + bool? terminal = null, + List? mounts = null, + bool? privileged = null, + string? hostname = null, + Namespace? netns = null, + List? portMappings = null, + string? restartPolicy = null, + ulong? stopTimeout = null, + List? capAdd = null, + List? capDrop = null, + List? dnsServers = null, + List? dnsSearch = null, + List? dnsOptions = null, + bool? publishImagePorts = null, + List? cniNetworks = null, + Dictionary? labels = null, + bool? readOnlyFilesystem = null, + List? rLimits = null, + List? devices = null, + string? ociRuntime = null, + string? pod = null, + bool? noNewPrivileges = null, + string? cgroupsMode = null, + Dictionary? storageOpts = null, + bool? unsetenvall = null, + Dictionary? secretEnv = null, + string? timezone = null, + Dictionary? sysctl = null, + string? seccompProfilePath = null, + string? seccompPolicy = null, + Dictionary? annotations = null, + string? apparmorProfile = null, + string? baseHostsFile = null, + string? cgroupParent = null, + Namespace? cgroupns = null, + List? chrootDirectories = null, + string? conmonPidFile = null, + List? containerCreateCommand = null, + bool? createWorkingDir = null, + List? dependencyContainers = null, + List? deviceCgroupRule = null, + List? devicesFrom = null, + List? entrypoint = null, + bool? envHost = null, + List? envMerge = null, + Dictionary? expose = null, + string? groupEntry = null, + List? groups = null, + long? healthCheckOnFailureAction = null, + Schema2HealthConfig? healthConfig = null, + List? hostDeviceList = null, + List? hostAdd = null, + List? hostUsers = null, + bool? envHTTPProxy = null, + IDMappingOptions? idMappings = null, + string? imageArch = null, + string? imageOS = null, + string? imageVariant = null, + string? imageVolumeMode = null, + List? imageVolumes = null, + bool? init = null, + string? initContainerType = null, + string? initPath = null, + LinuxIntelRdt? intelRdt = null, + Namespace? ipcns = null, + bool? labelNested = null, + LogConfigLibpod? logConfiguration = null, + bool? managePassword = null, + List? mask = null, + Dictionary? networkOptions = null, + Dictionary? networks = null, + long? oomScoreAdj = null, + List? overlayVolumes = null, + string? passwdEntry = null, + LinuxPersonality? personality = null, + Namespace? pidns = null, + string? rawImageName = null, + bool? readWriteTmpfs = null, + LinuxResources? resourceLimits = null, + ulong? restartTries = null, + string? rootfs = null, + string? rootfsMapping = null, + bool? rootfsOverlay = null, + string? rootfsPropagation = null, + string? sdnotifyMode = null, + List? secrets = null, + List? selinuxOpts = null, + long? shmSize = null, + long? shmSizeSystemd = null, + StartupHealthConfig? startupHealthConfig = null, + long? stopSignal = null, + string? systemd = null, + Dictionary? throttleReadBpsDevice = null, + Dictionary? throttleReadIopsDevice = null, + Dictionary? throttleWriteBpsDevice = null, + Dictionary? throttleWriteIopsDevice = null, + ulong? timeout = null, + string? umask = null, + Dictionary? unified = null, + List? unmask = null, + bool? useImageHosts = null, + bool? useImageResolvConf = null, + string? user = null, + Namespace? userns = null, + Namespace? utsns = null, + bool? volatileFlag = null, + List? volumes = null, + List? volumesFrom = null, + Dictionary? weightDevice = null, + string? workDir = null + ) { + var createContainerParameters = new CreateContainerRequest { + Name = name, + Image = image, + Command = command, + Env = env, + WorkDir = workDir, + Remove = remove, + Stdin = stdin, + Terminal = terminal, + Mounts = mounts, + Privileged = privileged, + Hostname = hostname, + Netns = netns, + Portmappings = portMappings, + RestartPolicy = restartPolicy, + StopTimeout = stopTimeout, + CapAdd = capAdd, + CapDrop = capDrop, + DNSServer = dnsServers, + DNSSearch = dnsSearch, + DNSOption = dnsOptions, + PublishImagePorts = publishImagePorts, + CNINetworks = cniNetworks, + Labels = labels, + ReadOnlyFilesystem = readOnlyFilesystem, + RLimits = rLimits, + Devices = devices, + OciRuntime = ociRuntime, + Pod = pod, + NoNewPrivileges = noNewPrivileges, + CgroupsMode = cgroupsMode, + StorageOpts = storageOpts, + Unmask = unmask, + Unsetenvall = unsetenvall, + SecretEnv = secretEnv, + Timezone = timezone, + Sysctl = sysctl, + SeccompProfilePath = seccompProfilePath, + SeccompPolicy = seccompPolicy, + Annotations = annotations, + ApparmorProfile = apparmorProfile, + BaseHostsFile = baseHostsFile, + CgroupParent = cgroupParent, + Cgroupns = cgroupns, + ChrootDirectories = chrootDirectories, + ConmonPidFile = conmonPidFile, + ContainerCreateCommand = containerCreateCommand, + CreateWorkingDir = createWorkingDir, + DependencyContainers = dependencyContainers, + DeviceCgroupRule = deviceCgroupRule, + DevicesFrom = devicesFrom, + Entrypoint = entrypoint, + EnvHost = envHost, + EnvMerge = envMerge, + Expose = expose, + GroupEntry = groupEntry, + Groups = groups, + HealthCheckOnFailureAction = healthCheckOnFailureAction, + HealthConfig = healthConfig, + HostDeviceList = hostDeviceList, + HostAdd = hostAdd, + HostUsers = hostUsers, + EnvHTTPProxy = envHTTPProxy, + IDMappings = idMappings, + ImageArch = imageArch, + ImageOS = imageOS, + ImageVariant = imageVariant, + ImageVolumeMode = imageVolumeMode, + ImageVolumes = imageVolumes, + Init = init, + InitContainerType = initContainerType, + InitPath = initPath, + IntelRdt = intelRdt, + Ipcns = ipcns, + LabelNested = labelNested, + LogConfiguration = logConfiguration, + ManagePassword = managePassword, + Mask = mask, + NetworkOptions = networkOptions, + Networks = networks, + OomScoreAdj = oomScoreAdj, + OverlayVolumes = overlayVolumes, + PasswdEntry = passwdEntry, + Personality = personality, + Pidns = pidns, + RawImageName = rawImageName, + ReadWriteTmpfs = readWriteTmpfs, + ResourceLimits = resourceLimits, + RestartTries = restartTries, + Rootfs = rootfs, + RootfsMapping = rootfsMapping, + RootfsOverlay = rootfsOverlay, + RootfsPropagation = rootfsPropagation, + SdnotifyMode = sdnotifyMode, + Secrets = secrets, + SelinuxOpts = selinuxOpts, + ShmSize = shmSize, + ShmSizeSystemd = shmSizeSystemd, + StartupHealthConfig = startupHealthConfig, + StopSignal = stopSignal, + Systemd = systemd, + ThrottleReadBpsDevice = throttleReadBpsDevice, + ThrottleReadIopsDevice = throttleReadIopsDevice, + ThrottleWriteBpsDevice = throttleWriteBpsDevice, + ThrottleWriteIopsDevice = throttleWriteIopsDevice, + Timeout = timeout, + Umask = umask, + Unified = unified, + UseImageHosts = useImageHosts, + UseImageResolvConf = useImageResolvConf, + User = user, + Userns = userns, + Utsns = utsns, + Volatile = volatileFlag, + Volumes = volumes, + VolumesFrom = volumesFrom, + WeightDevice = weightDevice + }; + + return await PostJsonAsync( + "/libpod/containers/create", + "Create container", + createContainerParameters + ).ConfigureAwait(false); + } + + public async Task StartContainerAsync(string containerId, string detachKeys = "ctrl-p,ctrl-q") { + var response = await _httpClient.PostAsync( + $"/{_apiVersion}/libpod/containers/{containerId}/start?detachKeys={Uri.EscapeDataString(detachKeys)}", + null + ); + + if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NotModified) { + var message = response.StatusCode == HttpStatusCode.NotModified + ? "Container was already started." + : "Container started successfully."; + + if (response.StatusCode == HttpStatusCode.NotModified) + _logger.LogWarning(message); + else + _logger.LogInformation(message); + + return PodmanHttpResults.Success(response.StatusCode, message); + } + + var body = await response.Content.ReadAsStringAsync(); + var errorMessage = PodmanHttpResults.GetErrorMessage(body); + PodmanHttpResults.LogFailure(_logger, response.StatusCode, "Start container", errorMessage); + return PodmanHttpResults.Failure(response.StatusCode, errorMessage); + } + + public async Task StopContainerAsync(string containerId, int timeout = 10, bool ignoreAlreadyStopped = false) { + var queryParams = $"?timeout={timeout}&Ignore={ignoreAlreadyStopped.ToString().ToLower()}"; + var response = await _httpClient.PostAsync($"/{_apiVersion}/libpod/containers/{containerId}/stop{queryParams}", null); + + if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NotModified) { + var message = response.StatusCode == HttpStatusCode.NotModified + ? "Container was already stopped." + : "Container stopped successfully."; + + if (response.StatusCode == HttpStatusCode.NotModified) + _logger.LogWarning(message); + else + _logger.LogInformation(message); + + return PodmanHttpResults.Success(response.StatusCode, message); + } + + var body = await response.Content.ReadAsStringAsync(); + var errorMessage = PodmanHttpResults.GetErrorMessage(body); + PodmanHttpResults.LogFailure(_logger, response.StatusCode, "Stop container", errorMessage); + return PodmanHttpResults.Failure(response.StatusCode, errorMessage); + } + + public async Task> ForceDeleteContainerAsync( + string containerId, + bool deleteVolumes = false, + int timeout = 10 + ) { + var queryParams = $"?force=true&v={deleteVolumes.ToString().ToLower()}&timeout={timeout}"; + var response = await _httpClient.DeleteAsync($"/{_apiVersion}/libpod/containers/{containerId}{queryParams}"); + var jsonResponse = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) { + var value = !string.IsNullOrWhiteSpace(jsonResponse) + ? jsonResponse.ToObject() + : null; + return PodmanHttpResults.Success(response.StatusCode, value); + } + + var message = PodmanHttpResults.GetErrorMessage(jsonResponse); + PodmanHttpResults.LogFailure(_logger, response.StatusCode, "Force delete container", message); + return PodmanHttpResults.Failure(response.StatusCode, message); + } + + public async Task> DeleteContainerAsync( + string containerId, + bool depend = false, + bool ignore = false, + int timeout = 10 + ) { + var queryParams = $"?depend={depend.ToString().ToLower()}&ignore={ignore.ToString().ToLower()}&timeout={timeout}"; + var response = await _httpClient.DeleteAsync($"/{_apiVersion}/libpod/containers/{containerId}{queryParams}"); + var jsonResponse = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) { + var value = !string.IsNullOrWhiteSpace(jsonResponse) + ? jsonResponse.ToObject() + : null; + return PodmanHttpResults.Success(response.StatusCode, value); + } + + var message = PodmanHttpResults.GetErrorMessage(jsonResponse); + PodmanHttpResults.LogFailure(_logger, response.StatusCode, "Delete container", message); + return PodmanHttpResults.Failure(response.StatusCode, message); + } + + + public async Task ExtractArchiveToContainerAsync(string containerId, Stream tarStream, string path, bool pause = true) { + var content = new StreamContent(tarStream); + content.Headers.Add("Content-Type", "application/x-tar"); + + var queryParams = $"?path={Uri.EscapeDataString(path)}&pause={pause.ToString().ToLower()}"; + var response = await _httpClient.PutAsync($"/{_apiVersion}/libpod/containers/{containerId}/archive{queryParams}", content); + var body = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) { + _logger.LogInformation("Files copied successfully to the container. {Response}", body.Trim()); + return PodmanHttpResults.Success(response.StatusCode, "Files copied successfully to the container."); + } + + var message = PodmanHttpResults.GetErrorMessage(body); + PodmanHttpResults.LogFailure(_logger, response.StatusCode, "Copy files to container", message); + return PodmanHttpResults.Failure(response.StatusCode, message); + } +} diff --git a/src/PodmanClient/PodmanClient.Containers.Api.cs b/src/PodmanClient/PodmanClient.Containers.Api.cs new file mode 100644 index 0000000..e55a248 --- /dev/null +++ b/src/PodmanClient/PodmanClient.Containers.Api.cs @@ -0,0 +1,282 @@ +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.Container; +using MaksIT.Results; + +public partial class PodmanClient { + private static string ContainerPath(string name) => $"/libpod/containers/{Uri.EscapeDataString(name)}"; + + public Task?>> ListContainersAsync( + bool all = false, + int? limit = null, + bool size = false, + bool sync = false, + string? filters = null, + CancellationToken cancellationToken = default + ) => + GetJsonAsync>( + "/libpod/containers/json", + "List containers", + [ + ("all", all.ToString().ToLowerInvariant()), + ("limit", limit?.ToString()), + ("size", size.ToString().ToLowerInvariant()), + ("sync", sync.ToString().ToLowerInvariant()), + ("filters", filters), + ], + cancellationToken + ); + + public Task> InspectContainerAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync($"{ContainerPath(name)}/json", "Inspect container", cancellationToken: cancellationToken); + + public Task ContainerExistsAsync(string name, CancellationToken cancellationToken = default) => + GetWithoutBodyAsync($"{ContainerPath(name)}/exists", "Container exists", cancellationToken: cancellationToken); + + public Task RestartContainerAsync(string name, int timeout = 10, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync( + $"{ContainerPath(name)}/restart", + "Restart container", + query: [("t", timeout.ToString())], + cancellationToken: cancellationToken + ); + + public Task KillContainerAsync(string name, string signal = "TERM", CancellationToken cancellationToken = default) => + PostWithoutBodyAsync( + $"{ContainerPath(name)}/kill", + "Kill container", + query: [("signal", signal)], + cancellationToken: cancellationToken + ); + + public Task PauseContainerAsync(string name, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync($"{ContainerPath(name)}/pause", "Pause container", cancellationToken: cancellationToken); + + public Task UnpauseContainerAsync(string name, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync($"{ContainerPath(name)}/unpause", "Unpause container", cancellationToken: cancellationToken); + + public Task> WaitContainerAsync(string name, string? condition = null, CancellationToken cancellationToken = default) => + PostLibpodAsync( + $"{ContainerPath(name)}/wait", + "Wait container", + query: condition is null ? null : [("condition", condition)], + cancellationToken: cancellationToken + ); + + public Task> GetContainerLogsAsync( + string name, + bool follow = false, + bool stdout = true, + bool stderr = true, + bool timestamps = false, + string? since = null, + string? until = null, + string? tail = null, + CancellationToken cancellationToken = default + ) => + GetStreamAsync( + $"{ContainerPath(name)}/logs", + "Get container logs", + [ + ("follow", follow.ToString().ToLowerInvariant()), + ("stdout", stdout.ToString().ToLowerInvariant()), + ("stderr", stderr.ToString().ToLowerInvariant()), + ("timestamps", timestamps.ToString().ToLowerInvariant()), + ("since", since), + ("until", until), + ("tail", tail), + ], + cancellationToken + ); + + public Task> GetContainerStatsAsync(string name, bool stream = false, CancellationToken cancellationToken = default) => + GetJsonAsync( + $"{ContainerPath(name)}/stats", + "Get container stats", + [("stream", stream.ToString().ToLowerInvariant())], + cancellationToken + ); + + public Task?>> GetContainersStatsAsync( + IEnumerable? containers = null, + bool stream = false, + CancellationToken cancellationToken = default + ) { + var query = new List<(string Key, string? Value)> { ("stream", stream.ToString().ToLowerInvariant()) }; + if (containers is not null) { + foreach (var c in containers) + query.Add(("containers", c)); + } + + return GetJsonAsync>("/libpod/containers/stats", "Get containers stats", query, cancellationToken); + } + + public Task> PruneContainersAsync(string? filters = null, CancellationToken cancellationToken = default) => + PostLibpodAsync( + "/libpod/containers/prune", + "Prune containers", + query: filters is null ? null : [("filters", filters)], + cancellationToken: cancellationToken + ); + + public Task RenameContainerAsync(string name, string newName, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync( + $"{ContainerPath(name)}/rename", + "Rename container", + query: [("name", newName)], + cancellationToken: cancellationToken + ); + + public Task InitContainerAsync(string name, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync($"{ContainerPath(name)}/init", "Init container", cancellationToken: cancellationToken); + + public Task> CheckpointContainerAsync( + string name, + bool keep = false, + bool leaveRunning = false, + bool tcpEstablished = false, + bool export = false, + bool ignoreRootFS = false, + CancellationToken cancellationToken = default + ) => + PostStreamAsync( + $"{ContainerPath(name)}/checkpoint", + "Checkpoint container", + query: [ + ("keep", keep.ToString().ToLowerInvariant()), + ("leaveRunning", leaveRunning.ToString().ToLowerInvariant()), + ("tcpEstablished", tcpEstablished.ToString().ToLowerInvariant()), + ("export", export.ToString().ToLowerInvariant()), + ("ignoreRootFS", ignoreRootFS.ToString().ToLowerInvariant()), + ], + cancellationToken: cancellationToken + ); + + public Task RestoreContainerAsync( + string name, + string? importPath = null, + bool keep = false, + bool leaveRunning = false, + bool tcpEstablished = false, + bool ignoreRootFS = false, + bool ignoreStaticIP = false, + bool ignoreStaticMAC = false, + CancellationToken cancellationToken = default + ) => + PostWithoutBodyAsync( + $"{ContainerPath(name)}/restore", + "Restore container", + query: [ + ("import", importPath), + ("keep", keep.ToString().ToLowerInvariant()), + ("leaveRunning", leaveRunning.ToString().ToLowerInvariant()), + ("tcpEstablished", tcpEstablished.ToString().ToLowerInvariant()), + ("ignoreRootFS", ignoreRootFS.ToString().ToLowerInvariant()), + ("ignoreStaticIP", ignoreStaticIP.ToString().ToLowerInvariant()), + ("ignoreStaticMAC", ignoreStaticMAC.ToString().ToLowerInvariant()), + ], + cancellationToken: cancellationToken + ); + + public Task> MountContainerAsync(string name, CancellationToken cancellationToken = default) => + PostLibpodAsync($"{ContainerPath(name)}/mount", "Mount container", cancellationToken: cancellationToken); + + public Task UnmountContainerAsync(string name, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync($"{ContainerPath(name)}/unmount", "Unmount container", cancellationToken: cancellationToken); + + public Task> ExportContainerAsync(string name, CancellationToken cancellationToken = default) => + GetStreamAsync($"{ContainerPath(name)}/export", "Export container", cancellationToken: cancellationToken); + + public Task> GetContainerArchiveAsync(string name, string path, CancellationToken cancellationToken = default) => + GetStreamAsync( + $"{ContainerPath(name)}/archive", + "Get container archive", + [("path", path)], + cancellationToken + ); + + public Task PutContainerArchiveAsync( + string containerId, + Stream tarStream, + string path, + bool pause = true, + CancellationToken cancellationToken = default + ) => + ExtractArchiveToContainerAsync(containerId, tarStream, path, pause); + + public Task> AttachContainerAsync( + string name, + bool logs = false, + bool stream = true, + bool stdout = true, + bool stderr = true, + bool stdin = false, + string? detachKeys = null, + CancellationToken cancellationToken = default + ) => + PostStreamAsync( + $"{ContainerPath(name)}/attach", + "Attach container", + query: [ + ("logs", logs.ToString().ToLowerInvariant()), + ("stream", stream.ToString().ToLowerInvariant()), + ("stdout", stdout.ToString().ToLowerInvariant()), + ("stderr", stderr.ToString().ToLowerInvariant()), + ("stdin", stdin.ToString().ToLowerInvariant()), + ("detachKeys", detachKeys), + ], + cancellationToken: cancellationToken + ); + + public Task> GetContainerChangesAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync($"{ContainerPath(name)}/changes", "Get container changes", cancellationToken: cancellationToken); + + public Task> CommitContainerAsync( + string container, + string? repo = null, + string? tag = null, + string? comment = null, + string? author = null, + bool pause = true, + IEnumerable? changes = null, + string? format = null, + CancellationToken cancellationToken = default + ) { + var query = new List<(string Key, string? Value)> { + ("container", container), + ("pause", pause.ToString().ToLowerInvariant()), + }; + if (repo is not null) query.Add(("repo", repo)); + if (tag is not null) query.Add(("tag", tag)); + if (comment is not null) query.Add(("comment", comment)); + if (author is not null) query.Add(("author", author)); + if (format is not null) query.Add(("format", format)); + if (changes is not null) { + foreach (var change in changes) + query.Add(("changes", change)); + } + + return PostLibpodAsync("/libpod/commit", "Commit container", query: query, cancellationToken: cancellationToken); + } + + public Task> HealthCheckContainerAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync($"{ContainerPath(name)}/healthcheck", "Health check container", cancellationToken: cancellationToken); + + public Task> GetMountedContainersAsync(CancellationToken cancellationToken = default) => + GetJsonAsync("/libpod/containers/showmounted", "Get mounted containers", cancellationToken: cancellationToken); + + public Task> TopContainerAsync( + string name, + string psArgs = "-ef", + bool stream = true, + CancellationToken cancellationToken = default + ) => + GetJsonAsync( + $"{ContainerPath(name)}/top", + "Top container", + [ + ("ps_args", psArgs), + ("stream", stream.ToString().ToLowerInvariant()), + ], + cancellationToken + ); +} diff --git a/src/PodmanClient/PodmanClient.Exec.cs b/src/PodmanClient/PodmanClient.Exec.cs new file mode 100644 index 0000000..051138f --- /dev/null +++ b/src/PodmanClient/PodmanClient.Exec.cs @@ -0,0 +1,88 @@ +using System.Text; + +using Microsoft.Extensions.Logging; + +using MaksIT.Core.Extensions; +using MaksIT.PodmanClientDotNet.Internal; +using MaksIT.PodmanClientDotNet.Models; +using MaksIT.PodmanClientDotNet.Dtos.Exec; +using MaksIT.PodmanClientDotNet.Models.Exec; +using MaksIT.Results; + +public partial class PodmanClient { + public async Task> CreateExecAsync( + string containerName, + string[] cmd, + bool attachStderr = true, + bool attachStdin = false, + bool attachStdout = true, + string? detachKeys = null, + string[]? env = null, + bool privileged = false, + bool tty = false, + string? user = null, + string? workingDir = null + ) { + var execRequest = new CreateExecRequest { + AttachStderr = attachStderr, + AttachStdin = attachStdin, + AttachStdout = attachStdout, + Cmd = cmd, + DetachKeys = detachKeys, + Env = env, + Privileged = privileged, + Tty = tty, + User = user, + WorkingDir = workingDir, + }; + + return await PostJsonAsync( + $"/libpod/containers/{Uri.EscapeDataString(containerName)}/exec", + "Create exec", + execRequest + ).ConfigureAwait(false); + } + + public async Task StartExecAsync( + string execId, + bool detach = false, + bool tty = false, + int? height = null, + int? width = null + ) { + var startExecRequest = new StartExecRequest { + Detach = detach, + Tty = tty, + Height = height, + Width = width, + }; + + var result = await PostJsonWithoutBodyAsync( + $"/libpod/exec/{Uri.EscapeDataString(execId)}/start", + "Start exec", + startExecRequest + ).ConfigureAwait(false); + + if (result.IsSuccess) + _logger.LogInformation("Exec started successfully."); + + return result; + } + + public Task> InspectExecAsync(string execId) => + GetJsonAsync( + $"/libpod/exec/{Uri.EscapeDataString(execId)}/json", + "Inspect exec" + ); + + public Task ResizeExecAsync(string execId, int height, int width, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync( + $"/libpod/exec/{Uri.EscapeDataString(execId)}/resize", + "Resize exec", + query: [ + ("h", height.ToString()), + ("w", width.ToString()), + ], + cancellationToken: cancellationToken + ); +} diff --git a/src/PodmanClient/PodmanClient.Generate.cs b/src/PodmanClient/PodmanClient.Generate.cs new file mode 100644 index 0000000..55cb0a9 --- /dev/null +++ b/src/PodmanClient/PodmanClient.Generate.cs @@ -0,0 +1,75 @@ +using System.Net.Http.Headers; + +using MaksIT.PodmanClientDotNet.Dtos.Generate; +using MaksIT.Results; + +public partial class PodmanClient { + public Task> GenerateSystemdAsync( + string name, + bool useName = false, + bool createNew = false, + int? restartSec = null, + string? restartPolicy = null, + string? containerPrefix = null, + string? podPrefix = null, + string? separator = null, + CancellationToken cancellationToken = default + ) => + GetJsonAsync( + $"/libpod/generate/{Uri.EscapeDataString(name)}/systemd", + "Generate systemd", + [ + ("useName", useName.ToString().ToLowerInvariant()), + ("new", createNew.ToString().ToLowerInvariant()), + ("restartSec", restartSec?.ToString()), + ("restartPolicy", restartPolicy), + ("containerPrefix", containerPrefix), + ("podPrefix", podPrefix), + ("separator", separator), + ], + cancellationToken + ); + + public Task> GenerateKubeAsync( + IEnumerable names, + bool service = false, + CancellationToken cancellationToken = default + ) { + var query = new List<(string Key, string? Value)> { + ("service", service.ToString().ToLowerInvariant()), + }; + foreach (var name in names) + query.Add(("names", name)); + + return SendAsync( + () => _httpClient.GetAsync(LibpodPath("/libpod/generate/kube") + BuildQuery(query), cancellationToken), + "Generate kube", + body => string.IsNullOrWhiteSpace(body) ? null : body.Trim('"'), + cancellationToken + ); + } + + public Task> PlayKubeAsync( + Stream yaml, + string? network = null, + bool tlsVerify = true, + bool start = true, + string? logDriver = null, + CancellationToken cancellationToken = default + ) { + var content = new StreamContent(yaml); + content.Headers.ContentType = new MediaTypeHeaderValue("application/yaml"); + return PostLibpodAsync( + "/libpod/play/kube", + "Play kube", + content, + [ + ("network", network), + ("tlsVerify", tlsVerify.ToString().ToLowerInvariant()), + ("start", start.ToString().ToLowerInvariant()), + ("logDriver", logDriver), + ], + cancellationToken + ); + } +} diff --git a/src/PodmanClient/PodmanClient.Http.cs b/src/PodmanClient/PodmanClient.Http.cs new file mode 100644 index 0000000..582069f --- /dev/null +++ b/src/PodmanClient/PodmanClient.Http.cs @@ -0,0 +1,276 @@ +using System.Net; +using System.Text; + +using MaksIT.Core.Extensions; +using MaksIT.PodmanClientDotNet.Internal; +using MaksIT.Results; + +public partial class PodmanClient { + private string LibpodPath(string path) { + if (!path.StartsWith('/')) + path = "/" + path; + return $"/{_apiVersion}{path}"; + } + + internal static string BuildQuery(IEnumerable<(string Key, string? Value)> parameters) { + var parts = parameters + .Where(p => p.Value is not null) + .Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value!)}") + .ToList(); + return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts); + } + + internal async Task> SendAsync( + Func> send, + string operation, + Func? deserialize = null, + CancellationToken cancellationToken = default + ) { + using var response = await send().ConfigureAwait(false); + var body = response.Content is null + ? string.Empty + : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (response.IsSuccessStatusCode) { + if (deserialize is null || string.IsNullOrWhiteSpace(body)) + return PodmanHttpResults.Success(response.StatusCode, default); + + return PodmanHttpResults.Success(response.StatusCode, deserialize(body)); + } + + var message = PodmanHttpResults.GetErrorMessage(body); + PodmanHttpResults.LogFailure(_logger, response.StatusCode, operation, message); + return PodmanHttpResults.Failure(response.StatusCode, message); + } + + internal async Task SendWithoutBodyAsync( + Func> send, + string operation, + CancellationToken cancellationToken = default + ) { + var result = await SendAsync(send, operation, cancellationToken: cancellationToken).ConfigureAwait(false); + return result.IsSuccess ? PodmanHttpResults.Success(result.StatusCode) : result.ToResult(); + } + + internal Task> GetJsonAsync( + string libpodPath, + string operation, + IEnumerable<(string Key, string? Value)>? query = null, + CancellationToken cancellationToken = default + ) => + SendAsync( + () => _httpClient.GetAsync(LibpodPath(libpodPath) + BuildQuery(query ?? []), cancellationToken), + operation, + body => body.ToObject(), + cancellationToken + ); + + internal Task GetWithoutBodyAsync( + string libpodPath, + string operation, + IEnumerable<(string Key, string? Value)>? query = null, + CancellationToken cancellationToken = default + ) => + SendWithoutBodyAsync( + () => _httpClient.GetAsync(LibpodPath(libpodPath) + BuildQuery(query ?? []), cancellationToken), + operation, + cancellationToken + ); + + internal Task> PostJsonAsync( + string libpodPath, + string operation, + TRequest? requestBody, + IEnumerable<(string Key, string? Value)>? query = null, + CancellationToken cancellationToken = default + ) { + var content = requestBody is null + ? null + : new StringContent(requestBody.ToJson(), Encoding.UTF8, "application/json"); + + return SendAsync( + () => _httpClient.PostAsync(LibpodPath(libpodPath) + BuildQuery(query ?? []), content, cancellationToken), + operation, + body => string.IsNullOrWhiteSpace(body) ? default : body.ToObject(), + cancellationToken + ); + } + + internal Task PostJsonWithoutBodyAsync( + string libpodPath, + string operation, + TRequest? requestBody, + IEnumerable<(string Key, string? Value)>? query = null, + CancellationToken cancellationToken = default + ) { + var content = requestBody is null + ? null + : new StringContent(requestBody.ToJson(), Encoding.UTF8, "application/json"); + + return SendWithoutBodyAsync( + () => _httpClient.PostAsync(LibpodPath(libpodPath) + BuildQuery(query ?? []), content, cancellationToken), + operation, + cancellationToken + ); + } + + internal Task PostWithoutBodyAsync( + string libpodPath, + string operation, + HttpContent? content = null, + IEnumerable<(string Key, string? Value)>? query = null, + string? registryAuthHeader = null, + CancellationToken cancellationToken = default + ) => + PostWithoutBodyCoreAsync( + libpodPath, + operation, + content, + query, + registryAuthHeader, + cancellationToken + ); + + private async Task PostWithoutBodyCoreAsync( + string libpodPath, + string operation, + HttpContent? content, + IEnumerable<(string Key, string? Value)>? query, + string? registryAuthHeader, + CancellationToken cancellationToken + ) { + using var request = CreateRequest( + HttpMethod.Post, + LibpodPath(libpodPath) + BuildQuery(query ?? []), + content, + registryAuthHeader + ); + return await SendWithoutBodyAsync( + () => _httpClient.SendAsync(request, cancellationToken), + operation, + cancellationToken + ).ConfigureAwait(false); + } + + internal Task> PostLibpodAsync( + string libpodPath, + string operation, + HttpContent? content = null, + IEnumerable<(string Key, string? Value)>? query = null, + CancellationToken cancellationToken = default + ) => + SendAsync( + () => _httpClient.PostAsync(LibpodPath(libpodPath) + BuildQuery(query ?? []), content, cancellationToken), + operation, + body => string.IsNullOrWhiteSpace(body) ? default : body.ToObject(), + cancellationToken + ); + + internal Task> DeleteJsonAsync( + string libpodPath, + string operation, + IEnumerable<(string Key, string? Value)>? query = null, + CancellationToken cancellationToken = default + ) => + SendAsync( + () => _httpClient.DeleteAsync(LibpodPath(libpodPath) + BuildQuery(query ?? []), cancellationToken), + operation, + body => string.IsNullOrWhiteSpace(body) ? default : body.ToObject(), + cancellationToken + ); + + internal Task DeleteWithoutBodyAsync( + string libpodPath, + string operation, + IEnumerable<(string Key, string? Value)>? query = null, + CancellationToken cancellationToken = default + ) => + SendWithoutBodyAsync( + () => _httpClient.DeleteAsync(LibpodPath(libpodPath) + BuildQuery(query ?? []), cancellationToken), + operation, + cancellationToken + ); + + internal Task PutWithoutBodyAsync( + string libpodPath, + string operation, + HttpContent? content = null, + IEnumerable<(string Key, string? Value)>? query = null, + CancellationToken cancellationToken = default + ) => + SendWithoutBodyAsync( + () => _httpClient.PutAsync(LibpodPath(libpodPath) + BuildQuery(query ?? []), content, cancellationToken), + operation, + cancellationToken + ); + + private static HttpRequestMessage CreateRequest( + HttpMethod method, + string requestUri, + HttpContent? content = null, + string? registryAuthHeader = null + ) { + var request = new HttpRequestMessage(method, requestUri) { Content = content }; + if (!string.IsNullOrEmpty(registryAuthHeader)) + request.Headers.TryAddWithoutValidation("X-Registry-Auth", registryAuthHeader); + return request; + } + + internal async Task> PostStreamAsync( + string libpodPath, + string operation, + HttpContent? content = null, + IEnumerable<(string Key, string? Value)>? query = null, + string? registryAuthHeader = null, + CancellationToken cancellationToken = default + ) { + using var request = CreateRequest( + HttpMethod.Post, + LibpodPath(libpodPath) + BuildQuery(query ?? []), + content, + registryAuthHeader + ); + var response = await _httpClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (response.IsSuccessStatusCode) { + var inner = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + return PodmanHttpResults.Success( + response.StatusCode, + new PodmanOwnedResponseStream(inner, response) + ); + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var message = PodmanHttpResults.GetErrorMessage(body); + PodmanHttpResults.LogFailure(_logger, response.StatusCode, operation, message); + response.Dispose(); + return PodmanHttpResults.Failure(response.StatusCode, message); + } + + internal async Task> GetStreamAsync( + string libpodPath, + string operation, + IEnumerable<(string Key, string? Value)>? query = null, + CancellationToken cancellationToken = default + ) { + var response = await _httpClient + .GetAsync(LibpodPath(libpodPath) + BuildQuery(query ?? []), HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (response.IsSuccessStatusCode) { + var inner = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + return PodmanHttpResults.Success( + response.StatusCode, + new PodmanOwnedResponseStream(inner, response) + ); + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var message = PodmanHttpResults.GetErrorMessage(body); + PodmanHttpResults.LogFailure(_logger, response.StatusCode, operation, message); + response.Dispose(); + return PodmanHttpResults.Failure(response.StatusCode, message); + } +} diff --git a/src/PodmanClient/PodmanClient.Image.cs b/src/PodmanClient/PodmanClient.Image.cs new file mode 100644 index 0000000..e194522 --- /dev/null +++ b/src/PodmanClient/PodmanClient.Image.cs @@ -0,0 +1,64 @@ +using MaksIT.PodmanClientDotNet.Internal; +using MaksIT.Results; + +using Microsoft.Extensions.Logging; + +public partial class PodmanClient { + public async Task PullImageAsync( + string reference, + bool tlsVerify = true, + bool quiet = false, + string policy = "always", + string? arch = null, + string? os = null, + string? variant = null, + bool allTags = false, + string? authHeader = null + ) { + var query = new List<(string Key, string? Value)> { + ("reference", reference), + ("tlsVerify", tlsVerify.ToString().ToLowerInvariant()), + ("quiet", quiet.ToString().ToLowerInvariant()), + ("policy", policy), + ("arch", arch), + ("OS", os), + ("Variant", variant), + }; + if (allTags) + query.Add(("allTags", "true")); + + var streamResult = await PostStreamAsync( + "/libpod/images/pull", + "Pull image", + query: query, + registryAuthHeader: authHeader + ).ConfigureAwait(false); + + if (!streamResult.IsSuccess) + return streamResult.ToResult(); + + return await PodmanNdjsonStreams.DrainPullOrPushAsync(streamResult.Value!, _logger, "Pull image") + .ConfigureAwait(false); + } + + public async Task TagImageAsync(string image, string repo, string tag) { + var response = await _httpClient.PostAsync( + $"/{_apiVersion}/libpod/images/{image}/tag?repo={Uri.EscapeDataString(repo)}&tag={Uri.EscapeDataString(tag)}", + null + ); + + using (response) { + if (response.IsSuccessStatusCode) { + _logger.LogInformation("Image tagged successfully."); + return response.StatusCode == System.Net.HttpStatusCode.Created + ? Result.Created("Image tagged successfully.") + : Result.Ok("Image tagged successfully."); + } + + var errorContent = await response.Content.ReadAsStringAsync(); + var errorMessage = PodmanHttpResults.GetErrorMessage(errorContent); + PodmanHttpResults.LogFailure(_logger, response.StatusCode, "Tag image", errorMessage); + return PodmanHttpResults.Failure(response.StatusCode, errorMessage); + } + } +} diff --git a/src/PodmanClient/PodmanClient.Images.Api.cs b/src/PodmanClient/PodmanClient.Images.Api.cs new file mode 100644 index 0000000..84ef97f --- /dev/null +++ b/src/PodmanClient/PodmanClient.Images.Api.cs @@ -0,0 +1,191 @@ +using System.Net.Http.Headers; + +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.Image; +using MaksIT.PodmanClientDotNet.Internal; +using MaksIT.Results; + +public partial class PodmanClient { + private static string ImagePath(string name) => $"/libpod/images/{Uri.EscapeDataString(name)}"; + + public Task?>> ListImagesAsync( + bool all = false, + string? filters = null, + CancellationToken cancellationToken = default + ) => + GetJsonAsync>( + "/libpod/images/json", + "List images", + [ + ("all", all.ToString().ToLowerInvariant()), + ("filters", filters), + ], + cancellationToken + ); + + public Task> InspectImageAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync($"{ImagePath(name)}/json", "Inspect image", cancellationToken: cancellationToken); + + 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( + ImagePath(name), + "Delete image", + [("force", force.ToString().ToLowerInvariant())], + cancellationToken + ); + + public Task> RemoveImagesAsync( + IEnumerable images, + bool all = false, + bool force = false, + CancellationToken cancellationToken = default + ) { + var query = new List<(string Key, string? Value)> { + ("all", all.ToString().ToLowerInvariant()), + ("force", force.ToString().ToLowerInvariant()), + }; + foreach (var image in images) + query.Add(("images", image)); + + return DeleteJsonAsync("/libpod/images/remove", "Remove images", query, cancellationToken); + } + + public Task> PruneImagesAsync(CancellationToken cancellationToken = default) => + PostLibpodAsync("/libpod/images/prune", "Prune images", cancellationToken: cancellationToken); + + public Task?>> SearchImagesAsync( + string term, + int? limit = null, + CancellationToken cancellationToken = default + ) => + GetJsonAsync>( + "/libpod/images/search", + "Search images", + [ + ("term", term), + ("limit", limit?.ToString()), + ], + cancellationToken + ); + + public async Task PushImageAsync( + string name, + string? destination = null, + bool tlsVerify = true, + bool compress = false, + string? authHeader = null, + CancellationToken cancellationToken = default + ) { + var streamResult = await PostStreamAsync( + $"{ImagePath(name)}/push", + "Push image", + query: [ + ("destination", destination), + ("tlsVerify", tlsVerify.ToString().ToLowerInvariant()), + ("compress", compress.ToString().ToLowerInvariant()), + ], + registryAuthHeader: authHeader, + cancellationToken: cancellationToken + ).ConfigureAwait(false); + + if (!streamResult.IsSuccess) + return streamResult.ToResult(); + + return await PodmanNdjsonStreams.DrainPullOrPushAsync(streamResult.Value!, _logger, "Push image", cancellationToken) + .ConfigureAwait(false); + } + + public Task UntagImageAsync( + string name, + string? repo = null, + string? tag = null, + CancellationToken cancellationToken = default + ) => + PostWithoutBodyAsync( + $"{ImagePath(name)}/untag", + "Untag image", + query: [ + ("repo", repo), + ("tag", tag), + ], + cancellationToken: cancellationToken + ); + + public Task?>> GetImageHistoryAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync>($"{ImagePath(name)}/history", "Get image history", cancellationToken: cancellationToken); + + public Task> GetImageTreeAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync($"{ImagePath(name)}/tree", "Get image tree", cancellationToken: cancellationToken); + + public Task> GetImageChangesAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync($"{ImagePath(name)}/changes", "Get image changes", cancellationToken: cancellationToken); + + public Task> ImportImageAsync( + Stream? tarball = null, + string? changes = null, + string? message = null, + string? reference = null, + string? url = null, + CancellationToken cancellationToken = default + ) { + HttpContent? content = null; + if (tarball is not null) { + content = new StreamContent(tarball); + content.Headers.ContentType = new MediaTypeHeaderValue("application/x-tar"); + } + + return PostLibpodAsync( + "/libpod/images/import", + "Import image", + content, + [ + ("changes", changes), + ("message", message), + ("reference", reference), + ("url", url), + ], + cancellationToken + ); + } + + public Task> LoadImageAsync(Stream tarball, CancellationToken cancellationToken = default) { + var content = new StreamContent(tarball); + content.Headers.ContentType = new MediaTypeHeaderValue("application/x-tar"); + return PostLibpodAsync("/libpod/images/load", "Load image", content, cancellationToken: cancellationToken); + } + + public Task> ExportImagesAsync( + IEnumerable references, + string? format = null, + bool compress = false, + CancellationToken cancellationToken = default + ) { + var query = new List<(string Key, string? Value)> { + ("compress", compress.ToString().ToLowerInvariant()), + ("format", format), + }; + foreach (var reference in references) + query.Add(("references", reference)); + + return GetStreamAsync("/libpod/images/export", "Export images", query, cancellationToken); + } + + public Task> GetImageAsync( + string name, + string? format = null, + bool compress = false, + CancellationToken cancellationToken = default + ) => + GetStreamAsync( + $"{ImagePath(name)}/get", + "Get image", + [ + ("format", format), + ("compress", compress.ToString().ToLowerInvariant()), + ], + cancellationToken + ); +} diff --git a/src/PodmanClient/PodmanClient.Manifests.cs b/src/PodmanClient/PodmanClient.Manifests.cs new file mode 100644 index 0000000..bb5d6e3 --- /dev/null +++ b/src/PodmanClient/PodmanClient.Manifests.cs @@ -0,0 +1,53 @@ +using MaksIT.PodmanClientDotNet.Dtos.Manifest; +using MaksIT.Results; + +public partial class PodmanClient { + private static string ManifestPath(string name) => $"/libpod/manifests/{Uri.EscapeDataString(name)}"; + + public Task> CreateManifestAsync( + string name, + string? image = null, + bool all = false, + CancellationToken cancellationToken = default + ) => + PostLibpodAsync( + "/libpod/manifests/create", + "Create manifest", + query: [ + ("name", name), + ("image", image), + ("all", all.ToString().ToLowerInvariant()), + ], + cancellationToken: cancellationToken + ); + + public Task DeleteManifestAsync(string name, string? digest = null, CancellationToken cancellationToken = default) => + DeleteWithoutBodyAsync( + ManifestPath(name), + "Delete manifest", + digest is null ? null : [("digest", digest)], + cancellationToken + ); + + public Task> InspectManifestAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync($"{ManifestPath(name)}/json", "Inspect manifest", cancellationToken: cancellationToken); + + public Task AddToManifestAsync(string name, ManifestAddRequestDto request, CancellationToken cancellationToken = default) => + PostJsonWithoutBodyAsync($"{ManifestPath(name)}/add", "Add to manifest", request, cancellationToken: cancellationToken); + + public Task PushManifestAsync( + string name, + string destination, + bool all = false, + CancellationToken cancellationToken = default + ) => + PostWithoutBodyAsync( + $"/libpod/manifests/{Uri.EscapeDataString(name)}/push", + "Push manifest", + query: [ + ("destination", destination), + ("all", all.ToString().ToLowerInvariant()), + ], + cancellationToken: cancellationToken + ); +} diff --git a/src/PodmanClient/PodmanClient.Networks.cs b/src/PodmanClient/PodmanClient.Networks.cs new file mode 100644 index 0000000..e5557d0 --- /dev/null +++ b/src/PodmanClient/PodmanClient.Networks.cs @@ -0,0 +1,49 @@ +using MaksIT.PodmanClientDotNet.Dtos.Network; +using MaksIT.PodmanClientDotNet.Models.Network; +using MaksIT.Results; + +public partial class PodmanClient { + public Task> CreateNetworkAsync( + NetworkCreateRequest request, + CancellationToken cancellationToken = default + ) => + PostJsonAsync( + "/libpod/networks/create", + "Create network", + request, + cancellationToken: cancellationToken + ); + + public Task?>> ListNetworksAsync(CancellationToken cancellationToken = default) => + GetJsonAsync>("/libpod/networks/json", "List networks", cancellationToken: cancellationToken); + + public Task> InspectNetworkAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync($"/libpod/networks/{Uri.EscapeDataString(name)}/json", "Inspect network", cancellationToken: cancellationToken); + + public Task DeleteNetworkAsync(string name, CancellationToken cancellationToken = default) => + DeleteWithoutBodyAsync($"/libpod/networks/{Uri.EscapeDataString(name)}", "Delete network", cancellationToken: cancellationToken); + + public Task ConnectNetworkAsync( + string name, + NetworkConnectRequest request, + CancellationToken cancellationToken = default + ) => + PostJsonWithoutBodyAsync( + $"/libpod/networks/{Uri.EscapeDataString(name)}/connect", + "Connect network", + request, + cancellationToken: cancellationToken + ); + + public Task DisconnectNetworkAsync( + string name, + NetworkDisconnectRequest request, + CancellationToken cancellationToken = default + ) => + PostJsonWithoutBodyAsync( + $"/libpod/networks/{Uri.EscapeDataString(name)}/disconnect", + "Disconnect network", + request, + cancellationToken: cancellationToken + ); +} diff --git a/src/PodmanClient/PodmanClient.Pods.cs b/src/PodmanClient/PodmanClient.Pods.cs new file mode 100644 index 0000000..744bcd2 --- /dev/null +++ b/src/PodmanClient/PodmanClient.Pods.cs @@ -0,0 +1,73 @@ +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.Pod; +using MaksIT.PodmanClientDotNet.Models.Pod; +using MaksIT.Results; + +public partial class PodmanClient { + public Task> CreatePodAsync(PodCreateRequest request, CancellationToken cancellationToken = default) => + PostJsonAsync("/libpod/pods/create", "Create pod", request, cancellationToken: cancellationToken); + + public Task?>> ListPodsAsync(bool all = false, CancellationToken cancellationToken = default) => + GetJsonAsync>( + "/libpod/pods/json", + "List pods", + [("all", all.ToString().ToLowerInvariant())], + cancellationToken + ); + + public Task> InspectPodAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync($"/libpod/pods/{Uri.EscapeDataString(name)}/json", "Inspect pod", cancellationToken: cancellationToken); + + public Task PodExistsAsync(string name, CancellationToken cancellationToken = default) => + GetWithoutBodyAsync($"/libpod/pods/{Uri.EscapeDataString(name)}/exists", "Pod exists", cancellationToken: cancellationToken); + + public Task DeletePodAsync(string name, bool force = false, CancellationToken cancellationToken = default) => + DeleteWithoutBodyAsync( + $"/libpod/pods/{Uri.EscapeDataString(name)}", + "Delete pod", + [("force", force.ToString().ToLowerInvariant())], + cancellationToken + ); + + public Task StartPodAsync(string name, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync($"/libpod/pods/{Uri.EscapeDataString(name)}/start", "Start pod", cancellationToken: cancellationToken); + + public Task StopPodAsync(string name, int timeout = 10, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync( + $"/libpod/pods/{Uri.EscapeDataString(name)}/stop", + "Stop pod", + query: [("t", timeout.ToString())], + cancellationToken: cancellationToken + ); + + public Task RestartPodAsync(string name, int timeout = 10, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync( + $"/libpod/pods/{Uri.EscapeDataString(name)}/restart", + "Restart pod", + query: [("t", timeout.ToString())], + cancellationToken: cancellationToken + ); + + public Task KillPodAsync(string name, string? signal = null, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync( + $"/libpod/pods/{Uri.EscapeDataString(name)}/kill", + "Kill pod", + query: signal is null ? null : [("signal", signal)], + cancellationToken: cancellationToken + ); + + public Task PausePodAsync(string name, CancellationToken cancellationToken = default) => + PostWithoutBodyAsync($"/libpod/pods/{Uri.EscapeDataString(name)}/pause", "Pause pod", cancellationToken: cancellationToken); + + 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", cancellationToken: cancellationToken); + + public Task> TopPodAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync($"/libpod/pods/{Uri.EscapeDataString(name)}/top", "Top pod", cancellationToken: cancellationToken); + + public Task> GetPodsStatsAsync(CancellationToken cancellationToken = default) => + GetJsonAsync("/libpod/pods/stats", "Get pods stats", cancellationToken: cancellationToken); +} diff --git a/src/PodmanClient/PodmanClient.Streaming.cs b/src/PodmanClient/PodmanClient.Streaming.cs new file mode 100644 index 0000000..5f435df --- /dev/null +++ b/src/PodmanClient/PodmanClient.Streaming.cs @@ -0,0 +1,181 @@ +using System.Text; + +using Microsoft.Extensions.Logging; + +using MaksIT.Core.Extensions; +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; + +public partial class PodmanClient { + public async Task> AttachContainerSessionAsync( + string name, + bool logs = false, + bool stream = true, + bool stdout = true, + bool stderr = true, + bool stdin = true, + bool tty = false, + string? detachKeys = null, + CancellationToken cancellationToken = default + ) { + try { + var query = BuildQuery([ + ("logs", logs.ToString().ToLowerInvariant()), + ("stream", stream.ToString().ToLowerInvariant()), + ("stdout", stdout.ToString().ToLowerInvariant()), + ("stderr", stderr.ToString().ToLowerInvariant()), + ("stdin", stdin.ToString().ToLowerInvariant()), + ("tty", tty.ToString().ToLowerInvariant()), + ("detachKeys", detachKeys), + ]); + + var hijack = await PodmanHijackConnection.ConnectAsync( + GetServerBaseAddress(), + _apiVersion, + HttpMethod.Post, + $"/libpod/containers/{Uri.EscapeDataString(name)}/attach", + query, + requestBody: null, + cancellationToken + ).ConfigureAwait(false); + + return Result.Ok(new PodmanAttachSession(hijack, tty)); + } + catch (Exception ex) { + _logger.LogError(ex, "Attach container session failed for {Name}", name); + return Result.InternalServerError(null, ex.Message); + } + } + + public async Task> StartExecSessionAsync( + string execId, + bool tty = false, + int? height = null, + int? width = null, + CancellationToken cancellationToken = default + ) { + try { + var startExecRequest = new StartExecRequest { + Detach = false, + Tty = tty, + Height = height, + Width = width, + }; + var body = Encoding.UTF8.GetBytes(startExecRequest.ToJson()); + var query = BuildQuery([]); + + var hijack = await PodmanHijackConnection.ConnectAsync( + GetServerBaseAddress(), + _apiVersion, + HttpMethod.Post, + $"/libpod/exec/{Uri.EscapeDataString(execId)}/start", + query, + body, + cancellationToken + ).ConfigureAwait(false); + + return Result.Ok(new PodmanAttachSession(hijack, tty)); + } + catch (Exception ex) { + _logger.LogError(ex, "Start exec session failed for {ExecId}", execId); + return Result.InternalServerError(null, ex.Message); + } + } + + public async Task?>> PullImageWithProgressAsync( + string reference, + bool tlsVerify = true, + bool quiet = false, + string policy = "always", + string? arch = null, + string? os = null, + string? variant = null, + bool allTags = false, + string? authHeader = null, + CancellationToken cancellationToken = default + ) { + var query = new List<(string Key, string? Value)> { + ("reference", reference), + ("tlsVerify", tlsVerify.ToString().ToLowerInvariant()), + ("quiet", quiet.ToString().ToLowerInvariant()), + ("policy", policy), + ("arch", arch), + ("OS", os), + ("Variant", variant), + }; + if (allTags) + query.Add(("allTags", "true")); + + var streamResult = await PostStreamAsync( + "/libpod/images/pull", + "Pull image with progress", + query: query, + registryAuthHeader: authHeader, + cancellationToken: cancellationToken + ).ConfigureAwait(false); + + if (!streamResult.IsSuccess) + return streamResult.ToResultOfType>(null!); + + return Result?>.Ok( + new PodmanProgressSession(streamResult.Value!) + ); + } + + public async Task?>> BuildImageWithProgressAsync( + string dockerfile, + Stream? context = null, + bool pull = false, + bool rm = true, + bool forcerm = false, + bool nocache = false, + string? remote = null, + string? t = null, + string? platform = null, + string? buildargs = null, + string? labels = null, + CancellationToken cancellationToken = default + ) { + HttpContent? content = null; + if (context is not null) { + content = new StreamContent(context); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-tar"); + } + + var streamResult = await PostStreamAsync( + "/libpod/build", + "Build image with progress", + content, + [ + ("dockerfile", dockerfile), + ("pull", pull.ToString().ToLowerInvariant()), + ("rm", rm.ToString().ToLowerInvariant()), + ("forcerm", forcerm.ToString().ToLowerInvariant()), + ("nocache", nocache.ToString().ToLowerInvariant()), + ("remote", remote), + ("t", t), + ("platform", platform), + ("buildargs", buildargs), + ("labels", labels), + ], + cancellationToken: cancellationToken + ).ConfigureAwait(false); + + if (!streamResult.IsSuccess) + return streamResult.ToResultOfType>(null!); + + return Result?>.Ok( + new PodmanProgressSession(streamResult.Value!) + ); + } + + private Uri GetServerBaseAddress() { + if (_httpClient.BaseAddress is null) + throw new InvalidOperationException("HttpClient BaseAddress is not configured."); + return _httpClient.BaseAddress; + } +} diff --git a/src/PodmanClient/PodmanClient.System.cs b/src/PodmanClient/PodmanClient.System.cs new file mode 100644 index 0000000..dddbd94 --- /dev/null +++ b/src/PodmanClient/PodmanClient.System.cs @@ -0,0 +1,23 @@ +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.System; +using MaksIT.Results; + +public partial class PodmanClient { + public Task> PingAsync(CancellationToken cancellationToken = default) => + GetJsonAsync("/libpod/_ping", "Ping", cancellationToken: cancellationToken); + + public Task> GetVersionAsync(CancellationToken cancellationToken = default) => + GetJsonAsync("/libpod/version", "Get version", cancellationToken: cancellationToken); + + public Task> GetInfoAsync(CancellationToken cancellationToken = default) => + GetJsonAsync("/libpod/info", "Get info", cancellationToken: cancellationToken); + + public Task> GetSystemDiskUsageAsync(CancellationToken cancellationToken = default) => + GetJsonAsync("/libpod/system/df", "Get system disk usage", cancellationToken: cancellationToken); + + public Task> PruneSystemAsync(CancellationToken cancellationToken = default) => + PostLibpodAsync("/libpod/system/prune", "Prune system", 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 new file mode 100644 index 0000000..c040a74 --- /dev/null +++ b/src/PodmanClient/PodmanClient.Volumes.cs @@ -0,0 +1,34 @@ +using MaksIT.PodmanClientDotNet.Dtos.Common; +using MaksIT.PodmanClientDotNet.Dtos.Volume; +using MaksIT.PodmanClientDotNet.Models.Volume; +using MaksIT.Results; + +public partial class PodmanClient { + public Task> CreateVolumeAsync( + CreateVolumeRequest request, + CancellationToken cancellationToken = default + ) => + PostJsonAsync( + "/libpod/volumes/create", + "Create volume", + request, + cancellationToken: cancellationToken + ); + + public Task?>> ListVolumesAsync(CancellationToken cancellationToken = default) => + GetJsonAsync>("/libpod/volumes/json", "List volumes", cancellationToken: cancellationToken); + + public Task> InspectVolumeAsync(string name, CancellationToken cancellationToken = default) => + GetJsonAsync($"/libpod/volumes/{Uri.EscapeDataString(name)}/json", "Inspect volume", cancellationToken: cancellationToken); + + public Task DeleteVolumeAsync(string name, bool force = false, CancellationToken cancellationToken = default) => + DeleteWithoutBodyAsync( + $"/libpod/volumes/{Uri.EscapeDataString(name)}", + "Delete volume", + [("force", force.ToString().ToLowerInvariant())], + cancellationToken + ); + + public Task> PruneVolumesAsync(CancellationToken cancellationToken = default) => + PostLibpodAsync("/libpod/volumes/prune", "Prune volumes", cancellationToken: cancellationToken); +} diff --git a/src/PodmanClient/PodmanClient.cs b/src/PodmanClient/PodmanClient.cs index 74c2a9e..557ff80 100644 --- a/src/PodmanClient/PodmanClient.cs +++ b/src/PodmanClient/PodmanClient.cs @@ -1,61 +1,73 @@ using Microsoft.Extensions.Logging; -namespace MaksIT.PodmanClientDotNet { - public partial class PodmanClient { - private readonly ILogger _logger; - private readonly HttpClient _httpClient; +using MaksIT.PodmanClientDotNet.Extensions; - private const string _apiVersion = "v1.41"; +/// +/// HTTP client for the Podman REST API. +/// +public partial class PodmanClient : IPodmanClient { + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly string _apiVersion; - /// - /// Initializes a new instance of the class using the specified base address and timeout. - /// - /// The logger instance used for logging within the client. - /// The base address of the Podman service. - /// The timeout period in minutes for HTTP requests. - public PodmanClient( - ILogger logger, - string baseAddress, - int timeOut = 60 - ) : this( - logger, - baseAddress, - new HttpClient { - Timeout = TimeSpan.FromMinutes(timeOut) - } - ) { } + /// + /// Initializes a new instance using a dedicated (not from ). + /// + public PodmanClient( + ILogger logger, + string baseAddress, + int timeOut = 60 + ) : this( + logger, + baseAddress, + new HttpClient { Timeout = TimeSpan.FromMinutes(timeOut) } + ) { } - /// - /// Initializes a new instance of the class using the provided instance. - /// - /// The logger instance used for logging within the client. - /// An existing instance configured for use with the Podman service. - /// Thrown when the logger or httpClient parameter is null. - public PodmanClient( - ILogger logger, - string serverUrl, - HttpClient httpClient - ) { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - if (serverUrl == null) - throw new ArgumentNullException(nameof(serverUrl)); + /// + /// Initializes a new instance with an existing and explicit server URL. + /// + public PodmanClient( + ILogger logger, + string serverUrl, + HttpClient httpClient + ) { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _apiVersion = "v1.41"; + ConfigureHttpClient(serverUrl); + } - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + /// + /// Initializes a typed client instance resolved via . + /// + public PodmanClient( + HttpClient httpClient, + ILogger logger, + IPodmanClientConfiguration configuration + ) { + ArgumentNullException.ThrowIfNull(configuration); - ConfigureHttpClient(serverUrl); - } + if (string.IsNullOrWhiteSpace(configuration.ServerUrl)) + throw new ArgumentException( + $"{nameof(IPodmanClientConfiguration.ServerUrl)} must be configured.", + nameof(configuration) + ); - /// - /// Configures the default settings for the used by this instance. - /// Ensures that the "Accept" header is set to "application/json". - /// - private void ConfigureHttpClient(string baseAddress) { - _httpClient.BaseAddress = new Uri(baseAddress); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _apiVersion = string.IsNullOrWhiteSpace(configuration.ApiVersion) + ? "v1.41" + : configuration.ApiVersion; + _httpClient.Timeout = TimeSpan.FromMinutes(Math.Max(1, configuration.TimeoutMinutes)); + ConfigureHttpClient(configuration.ServerUrl); + } - if (_httpClient.DefaultRequestHeaders.Contains("Accept")) - _httpClient.DefaultRequestHeaders.Remove("Accept"); + private void ConfigureHttpClient(string baseAddress) { + _httpClient.BaseAddress = new Uri(baseAddress.TrimEnd('/') + "/"); - _httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); - } + if (_httpClient.DefaultRequestHeaders.Contains("Accept")) + _httpClient.DefaultRequestHeaders.Remove("Accept"); + + _httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); } } diff --git a/src/PodmanClient/PodmanClientContainer.cs b/src/PodmanClient/PodmanClientContainer.cs deleted file mode 100644 index 03f03ec..0000000 --- a/src/PodmanClient/PodmanClientContainer.cs +++ /dev/null @@ -1,496 +0,0 @@ -using System.Text; -using System.Text.Json; - -using Microsoft.Extensions.Logging; - -using MaksIT.PodmanClientDotNet.Models; -using MaksIT.PodmanClientDotNet.Models.Container; -using MaksIT.PodmanClientDotNet.Extensions; -using MaksIT.PodmanClientDotNet.Models.Exec; - - -namespace MaksIT.PodmanClientDotNet { - public partial class PodmanClient { - public async Task CreateContainerAsync( - string name, - string image, - List command = null, - Dictionary env = null, - bool? remove = null, - bool? stdin = null, - bool? terminal = null, - List mounts = null, - bool? privileged = null, - string hostname = null, - Namespace netns = null, - List portMappings = null, - string restartPolicy = null, - ulong? stopTimeout = null, - List capAdd = null, - List capDrop = null, - List dnsServers = null, - List dnsSearch = null, - List dnsOptions = null, - bool? publishImagePorts = null, - List cniNetworks = null, - Dictionary labels = null, - bool? readOnlyFilesystem = null, - List rLimits = null, - List devices = null, - string ociRuntime = null, - string pod = null, - bool? noNewPrivileges = null, - string cgroupsMode = null, - Dictionary storageOpts = null, - bool? unsetenvall = null, - Dictionary secretEnv = null, - string timezone = null, - Dictionary sysctl = null, - string seccompProfilePath = null, - string seccompPolicy = null, - Dictionary annotations = null, - string apparmorProfile = null, - string baseHostsFile = null, - string cgroupParent = null, - Namespace cgroupns = null, - List chrootDirectories = null, - string conmonPidFile = null, - List containerCreateCommand = null, - bool? createWorkingDir = null, - List dependencyContainers = null, - List deviceCgroupRule = null, - List devicesFrom = null, - List entrypoint = null, - bool? envHost = null, - List envMerge = null, - Dictionary expose = null, - string groupEntry = null, - List groups = null, - long? healthCheckOnFailureAction = null, - Schema2HealthConfig healthConfig = null, - List hostDeviceList = null, - List hostAdd = null, - List hostUsers = null, - bool? envHTTPProxy = null, - IDMappingOptions idMappings = null, - string imageArch = null, - string imageOS = null, - string imageVariant = null, - string imageVolumeMode = null, - List imageVolumes = null, - bool? init = null, - string initContainerType = null, - string initPath = null, - LinuxIntelRdt intelRdt = null, - Namespace ipcns = null, - bool? labelNested = null, - LogConfigLibpod logConfiguration = null, - bool? managePassword = null, - List mask = null, - Dictionary networkOptions = null, - Dictionary networks = null, - long? oomScoreAdj = null, - List overlayVolumes = null, - string passwdEntry = null, - LinuxPersonality personality = null, - Namespace pidns = null, - string rawImageName = null, - bool? readWriteTmpfs = null, - LinuxResources resourceLimits = null, - ulong? restartTries = null, - string rootfs = null, - string rootfsMapping = null, - bool? rootfsOverlay = null, - string rootfsPropagation = null, - string sdnotifyMode = null, - List secrets = null, - List selinuxOpts = null, - long? shmSize = null, - long? shmSizeSystemd = null, - StartupHealthConfig startupHealthConfig = null, - long? stopSignal = null, - string systemd = null, - Dictionary throttleReadBpsDevice = null, - Dictionary throttleReadIopsDevice = null, - Dictionary throttleWriteBpsDevice = null, - Dictionary throttleWriteIopsDevice = null, - ulong? timeout = null, - string umask = null, - Dictionary unified = null, - List unmask = null, - bool? useImageHosts = null, - bool? useImageResolvConf = null, - string user = null, - Namespace userns = null, - Namespace utsns = null, - bool? volatileFlag = null, - List volumes = null, - List volumesFrom = null, - Dictionary weightDevice = null, - string workDir = null - ) { - var createContainerParameters = new CreateContainerRequest { - Name = name, - Image = image, - Command = command, - Env = env, - WorkDir = workDir, - Remove = remove, - Stdin = stdin, - Terminal = terminal, - Mounts = mounts, - Privileged = privileged, - Hostname = hostname, - Netns = netns, - Portmappings = portMappings, - RestartPolicy = restartPolicy, - StopTimeout = stopTimeout, - CapAdd = capAdd, - CapDrop = capDrop, - DNSServer = dnsServers, - DNSSearch = dnsSearch, - DNSOption = dnsOptions, - PublishImagePorts = publishImagePorts, - CNINetworks = cniNetworks, - Labels = labels, - ReadOnlyFilesystem = readOnlyFilesystem, - RLimits = rLimits, - Devices = devices, - OciRuntime = ociRuntime, - Pod = pod, - NoNewPrivileges = noNewPrivileges, - CgroupsMode = cgroupsMode, - StorageOpts = storageOpts, - Unmask = unmask, - Unsetenvall = unsetenvall, - SecretEnv = secretEnv, - Timezone = timezone, - Sysctl = sysctl, - SeccompProfilePath = seccompProfilePath, - SeccompPolicy = seccompPolicy, - Annotations = annotations, - ApparmorProfile = apparmorProfile, - BaseHostsFile = baseHostsFile, - CgroupParent = cgroupParent, - Cgroupns = cgroupns, - ChrootDirectories = chrootDirectories, - ConmonPidFile = conmonPidFile, - ContainerCreateCommand = containerCreateCommand, - CreateWorkingDir = createWorkingDir, - DependencyContainers = dependencyContainers, - DeviceCgroupRule = deviceCgroupRule, - DevicesFrom = devicesFrom, - Entrypoint = entrypoint, - EnvHost = envHost, - EnvMerge = envMerge, - Expose = expose, - GroupEntry = groupEntry, - Groups = groups, - HealthCheckOnFailureAction = healthCheckOnFailureAction, - HealthConfig = healthConfig, - HostDeviceList = hostDeviceList, - HostAdd = hostAdd, - HostUsers = hostUsers, - EnvHTTPProxy = envHTTPProxy, - IDMappings = idMappings, - ImageArch = imageArch, - ImageOS = imageOS, - ImageVariant = imageVariant, - ImageVolumeMode = imageVolumeMode, - ImageVolumes = imageVolumes, - Init = init, - InitContainerType = initContainerType, - InitPath = initPath, - IntelRdt = intelRdt, - Ipcns = ipcns, - LabelNested = labelNested, - LogConfiguration = logConfiguration, - ManagePassword = managePassword, - Mask = mask, - NetworkOptions = networkOptions, - Networks = networks, - OomScoreAdj = oomScoreAdj, - OverlayVolumes = overlayVolumes, - PasswdEntry = passwdEntry, - Personality = personality, - Pidns = pidns, - RawImageName = rawImageName, - ReadWriteTmpfs = readWriteTmpfs, - ResourceLimits = resourceLimits, - RestartTries = restartTries, - Rootfs = rootfs, - RootfsMapping = rootfsMapping, - RootfsOverlay = rootfsOverlay, - RootfsPropagation = rootfsPropagation, - SdnotifyMode = sdnotifyMode, - Secrets = secrets, - SelinuxOpts = selinuxOpts, - ShmSize = shmSize, - ShmSizeSystemd = shmSizeSystemd, - StartupHealthConfig = startupHealthConfig, - StopSignal = stopSignal, - Systemd = systemd, - ThrottleReadBpsDevice = throttleReadBpsDevice, - ThrottleReadIopsDevice = throttleReadIopsDevice, - ThrottleWriteBpsDevice = throttleWriteBpsDevice, - ThrottleWriteIopsDevice = throttleWriteIopsDevice, - Timeout = timeout, - Umask = umask, - Unified = unified, - UseImageHosts = useImageHosts, - UseImageResolvConf = useImageResolvConf, - User = user, - Userns = userns, - Utsns = utsns, - Volatile = volatileFlag, - Volumes = volumes, - VolumesFrom = volumesFrom, - WeightDevice = weightDevice - }; - - var jsonContent = new StringContent(JsonSerializer.Serialize(createContainerParameters), Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync($"/{_apiVersion}/libpod/containers/create", jsonContent); - - if (response.IsSuccessStatusCode) { - var jsonResponse = await response.Content.ReadAsStringAsync(); - return !string.IsNullOrWhiteSpace(jsonResponse) - ? jsonResponse.ToObject() - : null; - } - else { - var errorContent = await response.Content.ReadAsStringAsync(); - var errorDetails = errorContent.ToObject(); - - switch (response.StatusCode) { - case System.Net.HttpStatusCode.BadRequest: - _logger.LogError($"Bad parameter in request: {errorDetails?.Message}"); - break; - case System.Net.HttpStatusCode.NotFound: - _logger.LogError($"No such container: {errorDetails?.Message}"); - break; - case System.Net.HttpStatusCode.Conflict: - _logger.LogError($"Conflict error in operation: {errorDetails?.Message}"); - break; - case System.Net.HttpStatusCode.InternalServerError: - _logger.LogError($"Internal server error: {errorDetails?.Message}"); - break; - default: - _logger.LogError($"Error creating container: {errorDetails?.Message}"); - break; - } - - response.EnsureSuccessStatusCode(); - return null; - } - } - - - - public async Task StartContainerAsync(string containerId, string detachKeys = "ctrl-p,ctrl-q") { - var response = await _httpClient.PostAsync( - $"/{_apiVersion}/libpod/containers/{containerId}/start?detachKeys={Uri.EscapeDataString(detachKeys)}", null); - - - if (response.IsSuccessStatusCode) { - var jsonResponse = await response.Content.ReadAsStringAsync(); - } - else { - - } - - switch (response.StatusCode) { - case System.Net.HttpStatusCode.NoContent: - _logger.LogInformation("Container started successfully."); - break; - - case System.Net.HttpStatusCode.NotModified: - _logger.LogWarning("Container was already started."); - break; - - case System.Net.HttpStatusCode.NotFound: - var errorContent404 = await response.Content.ReadAsStringAsync(); - var errorDetails404 = errorContent404.ToObject(); - _logger.LogError($"Container not found: {errorDetails404?.Message}"); - break; - - case System.Net.HttpStatusCode.InternalServerError: - var errorContent500 = await response.Content.ReadAsStringAsync(); - var errorDetails500 = errorContent500.ToObject(); - _logger.LogError($"Internal server error: {errorDetails500?.Message}"); - break; - - default: - if ((int)response.StatusCode >= 400) { - var errorContent = await response.Content.ReadAsStringAsync(); - var errorDetails = errorContent.ToObject(); - _logger.LogError($"Error starting container: {errorDetails?.Message}"); - } - break; - } - - response.EnsureSuccessStatusCode(); - } - - public async Task StopContainerAsync(string containerId, int timeout = 10, bool ignoreAlreadyStopped = false) { - var queryParams = $"?timeout={timeout}&Ignore={ignoreAlreadyStopped.ToString().ToLower()}"; - var response = await _httpClient.PostAsync($"/{_apiVersion}/libpod/containers/{containerId}/stop{queryParams}", null); - - if (response.IsSuccessStatusCode) { - var jsonResponse = await response.Content.ReadAsStringAsync(); - - if (response.StatusCode == System.Net.HttpStatusCode.NoContent) { - _logger.LogInformation("Container stopped successfully."); - } - else if (response.StatusCode == System.Net.HttpStatusCode.NotModified) { - _logger.LogWarning("Container was already stopped."); - } - } - else { - var errorContent = await response.Content.ReadAsStringAsync(); - var errorDetails = JsonSerializer.Deserialize(errorContent); - - switch (response.StatusCode) { - case System.Net.HttpStatusCode.NotFound: - _logger.LogError($"No such container: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.InternalServerError: - _logger.LogError($"Internal server error: {errorDetails?.Message}"); - break; - - default: - _logger.LogError($"Error stopping container: {errorDetails?.Message}"); - break; - } - - response.EnsureSuccessStatusCode(); - } - } - - public async Task ForceDeleteContainerAsync(string containerId, bool deleteVolumes = false, int timeout = 10) { - var queryParams = $"?force=true&v={deleteVolumes.ToString().ToLower()}&timeout={timeout}"; - var response = await _httpClient.DeleteAsync($"/{_apiVersion}/libpod/containers/{containerId}{queryParams}"); - - if (response.IsSuccessStatusCode) { - var jsonResponse = await response.Content.ReadAsStringAsync(); - return !string.IsNullOrWhiteSpace(jsonResponse) - ? jsonResponse.ToObject() - : null; - } - else { - var errorContent = await response.Content.ReadAsStringAsync(); - var errorDetails = errorContent.ToObject(); - - switch (response.StatusCode) { - case System.Net.HttpStatusCode.BadRequest: - _logger.LogError($"Bad parameter in request: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.NotFound: - _logger.LogError($"No such container: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.Conflict: - _logger.LogError($"Conflict error: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.InternalServerError: - _logger.LogError($"Internal server error: {errorDetails?.Message}"); - break; - - default: - _logger.LogError($"Error deleting container: {errorDetails?.Message}"); - break; - } - - response.EnsureSuccessStatusCode(); - return null; - } - } - - public async Task DeleteContainerAsync(string containerId, bool depend = false, bool ignore = false, int timeout = 10) { - var queryParams = $"?depend={depend.ToString().ToLower()}&ignore={ignore.ToString().ToLower()}&timeout={timeout}"; - var response = await _httpClient.DeleteAsync($"/{_apiVersion}/containers/{containerId}{queryParams}"); - - if (response.IsSuccessStatusCode) { - var jsonResponse = await response.Content.ReadAsStringAsync(); - return !string.IsNullOrWhiteSpace(jsonResponse) - ? jsonResponse.ToObject() - : null; - } - else { - var errorContent = await response.Content.ReadAsStringAsync(); - var errorDetails = errorContent.ToObject(); - - switch (response.StatusCode) { - case System.Net.HttpStatusCode.BadRequest: - _logger.LogInformation($"Bad parameter in request: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.NotFound: - _logger.LogInformation($"No such container: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.Conflict: - _logger.LogInformation($"Conflict error: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.InternalServerError: - _logger.LogInformation($"Internal server error: {errorDetails?.Message}"); - break; - - default: - _logger.LogInformation($"Error deleting container: {errorDetails?.Message}"); - break; - } - - response.EnsureSuccessStatusCode(); - return null; - } - - } - - - public async Task ExtractArchiveToContainerAsync(string containerId, Stream tarStream, string path, bool pause = true) { - var content = new StreamContent(tarStream); - content.Headers.Add("Content-Type", "application/x-tar"); - - var queryParams = $"?path={Uri.EscapeDataString(path)}&pause={pause.ToString().ToLower()}"; - var response = await _httpClient.PutAsync($"/{_apiVersion}/libpod/containers/{containerId}/archive{queryParams}", content); - - if (response.IsSuccessStatusCode) { - var stringResponse = await response.Content.ReadAsStringAsync(); - _logger.LogInformation($"Files copied successfully to the container.\n\n{stringResponse}".Trim()); - } - else { - var errorContent = await response.Content.ReadAsStringAsync(); - var errorDetails = errorContent.ToObject(); - - switch (response.StatusCode) { - case System.Net.HttpStatusCode.BadRequest: - _logger.LogError($"Bad parameter in request: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.Forbidden: - _logger.LogError($"The container root filesystem is read-only: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.NotFound: - _logger.LogError($"No such container: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.InternalServerError: - _logger.LogError($"Internal server error: {errorDetails?.Message}"); - break; - - default: - _logger.LogError($"Error copying files: {errorDetails?.Message}"); - break; - } - - response.EnsureSuccessStatusCode(); - } - } - - } -} diff --git a/src/PodmanClient/PodmanClientDotNet.csproj b/src/PodmanClient/PodmanClientDotNet.csproj index 1209ede..81bfdca 100644 --- a/src/PodmanClient/PodmanClientDotNet.csproj +++ b/src/PodmanClient/PodmanClientDotNet.csproj @@ -1,28 +1,67 @@  - net8.0 + net10.0 enable enable MaksIT.$(MSBuildProjectName.Replace(" ", "_")) + + true + $(NoWarn);CS1591 + PodmanClient.DotNet - 1.0.4 + 1.1.0 Maksym Sadovnychyy MAKS-IT PodmanClient.DotNet - Podman API client .NET implementation - podman client dotnet + Copyright © Maksym Sadovnychyy (MAKS-IT) + Podman API client for .NET — manage containers, images, exec sessions, and archives via the Podman REST API. + podman;container;docker;client;dotnet;api + + https://github.com/MAKS-IT-COM/podman-client-dotnet https://github.com/MAKS-IT-COM/podman-client-dotnet - MIT - false + git + README.md + LICENSE.md + See https://github.com/MAKS-IT-COM/podman-client-dotnet/releases + + false + + + true + true + true + snupkg + + + true + true - - + + + + + + + + + + + + + + + + + + + PreserveNewest + diff --git a/src/PodmanClient/PodmanClientExec.cs b/src/PodmanClient/PodmanClientExec.cs deleted file mode 100644 index b039265..0000000 --- a/src/PodmanClient/PodmanClientExec.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Text; - -using Microsoft.Extensions.Logging; - -using MaksIT.PodmanClientDotNet.Models; -using MaksIT.PodmanClientDotNet.Models.Exec; -using MaksIT.PodmanClientDotNet.Extensions; - - -namespace MaksIT.PodmanClientDotNet { - public partial class PodmanClient { - - public async Task CreateExecAsync( - string containerName, - string[] cmd, - bool attachStderr = true, - bool attachStdin = false, - bool attachStdout = true, - string detachKeys = null, - string[] env = null, - bool privileged = false, - bool tty = false, - string user = null, - string workingDir = null - ) { - // Construct the request object - var execRequest = new CreateExecRequest { - AttachStderr = attachStderr, - AttachStdin = attachStdin, - AttachStdout = attachStdout, - Cmd = cmd, - DetachKeys = detachKeys, - Env = env, - Privileged = privileged, - Tty = tty, - User = user, - WorkingDir = workingDir - }; - - // Serialize the request object to JSON - var jsonRequest = execRequest.ToJson(); - var content = new StringContent(jsonRequest, Encoding.UTF8, "application/json"); - - // Create the request URL - var requestUrl = $"/{_apiVersion}/containers/{Uri.EscapeDataString(containerName)}/exec"; - - // Send the POST request - var response = await _httpClient.PostAsync(requestUrl, content); - - if (response.IsSuccessStatusCode) { - var jsonResponse = await response.Content.ReadAsStringAsync(); - return !string.IsNullOrWhiteSpace(jsonResponse) - ? jsonResponse.ToObject() - : null; - } - else { - var jsonResponse = await response.Content.ReadAsStringAsync(); - var errorResponse = jsonResponse.ToObject(); - - // Handle different response codes - switch (response.StatusCode) { - case System.Net.HttpStatusCode.NotFound: - _logger.LogInformation($"No such container: {errorResponse?.Message}"); - break; - case System.Net.HttpStatusCode.Conflict: - _logger.LogInformation($"Conflict error: {errorResponse?.Message}"); - break; - case System.Net.HttpStatusCode.InternalServerError: - _logger.LogInformation($"Internal server error: {errorResponse?.Message}"); - break; - default: - _logger.LogInformation($"Error creating exec instance: {errorResponse?.Message}"); - break; - } - - response.EnsureSuccessStatusCode(); - return null; - } - } - - public async Task StartExecAsync( - string execId, - bool detach = false, - bool tty = false, - int? height = null, - int? width = null -) { - // Construct the request object - var startExecRequest = new StartExecRequest { - Detach = detach, - Tty = tty, - Height = height, - Width = width - }; - - // Serialize the request object to JSON - var jsonRequest = startExecRequest.ToJson(); - var content = new StringContent(jsonRequest, Encoding.UTF8, "application/json"); - - // Create the request URL - var requestUrl = $"/{_apiVersion}/exec/{Uri.EscapeDataString(execId)}/start"; - - // Send the POST request - var response = await _httpClient.PostAsync(requestUrl, content); - - if (response.IsSuccessStatusCode) { - var stringResponse = await response.Content.ReadAsStringAsync(); - _logger.LogInformation($"Command executed successfully.\n\n{stringResponse}".Trim()); - } - else { - var jsonResponse = await response.Content.ReadAsStringAsync(); - var errorResponse = jsonResponse.ToObject(); - - // Handle different response codes - switch (response.StatusCode) { - case System.Net.HttpStatusCode.NotFound: - _logger.LogWarning($"No such exec instance: {errorResponse?.Message}"); - break; - case System.Net.HttpStatusCode.Conflict: - _logger.LogError($"Conflict error: {errorResponse?.Message}"); - break; - case System.Net.HttpStatusCode.InternalServerError: - _logger.LogError($"Internal server error: {errorResponse?.Message}"); - break; - default: - _logger.LogError($"Error starting exec instance: {errorResponse?.Message}"); - break; - } - - response.EnsureSuccessStatusCode(); // Throws an exception if the response indicates an error - } - } - - - public async Task InspectExecAsync(string execId) { - var requestUrl = $"/{_apiVersion}/exec/{Uri.EscapeDataString(execId)}/json"; - var response = await _httpClient.GetAsync(requestUrl); - - if (response.IsSuccessStatusCode) { - var jsonResponse = await response.Content.ReadAsStringAsync(); - return !string.IsNullOrWhiteSpace(jsonResponse) - ? jsonResponse.ToObject() - : null; - } - else { - var jsonResponse = await response.Content.ReadAsStringAsync(); - var errorResponse = jsonResponse.ToObject(); - - switch (response.StatusCode) { - case System.Net.HttpStatusCode.NotFound: - _logger.LogWarning($"No such exec instance: {errorResponse?.Message}"); - return null; - - case System.Net.HttpStatusCode.InternalServerError: - _logger.LogError($"Internal server error: {errorResponse?.Message}"); - break; - - default: - _logger.LogError($"Error inspecting exec instance: {errorResponse?.Message}"); - break; - } - - return null; - } - } - - } -} diff --git a/src/PodmanClient/PodmanClientImage.cs b/src/PodmanClient/PodmanClientImage.cs deleted file mode 100644 index c47d658..0000000 --- a/src/PodmanClient/PodmanClientImage.cs +++ /dev/null @@ -1,97 +0,0 @@ -using Microsoft.Extensions.Logging; - -using MaksIT.PodmanClientDotNet.Models; -using MaksIT.PodmanClientDotNet.Models.Image; -using MaksIT.PodmanClientDotNet.Extensions; - -namespace MaksIT.PodmanClientDotNet { - public partial class PodmanClient { - - public async Task PullImageAsync(string reference, bool tlsVerify = true, bool quiet = false, string policy = "always", string arch = null, string os = null, string variant = null, bool allTags = false, string authHeader = null) { - var query = $"reference={Uri.EscapeDataString(reference)}&tlsVerify={tlsVerify}&quiet={quiet}&policy={Uri.EscapeDataString(policy)}"; - if (!string.IsNullOrEmpty(arch)) query += $"&Arch={Uri.EscapeDataString(arch)}"; - if (!string.IsNullOrEmpty(os)) query += $"&OS={Uri.EscapeDataString(os)}"; - if (!string.IsNullOrEmpty(variant)) query += $"&Variant={Uri.EscapeDataString(variant)}"; - if (allTags) query += "&allTags=true"; - - if (!string.IsNullOrEmpty(authHeader)) { - _httpClient.DefaultRequestHeaders.Add("X-Registry-Auth", authHeader); - } - - var response = await _httpClient.PostAsync($"/{_apiVersion}/libpod/images/pull?{query}", null); - - if (response.IsSuccessStatusCode) { - var responseStream = await response.Content.ReadAsStreamAsync(); - using (var reader = new StreamReader(responseStream)) { - string line; - while ((line = await reader.ReadLineAsync()) != null) { - if (line.StartsWith("{\"error\"")) { - var errorDetails = line.ToObject(); - _logger.LogError($"Error pulling image: {errorDetails?.Error}"); - throw new HttpRequestException($"Forced exception: {response.StatusCode} - {errorDetails?.Error}"); - } - } - } - } - else { - var errorContent = await response.Content.ReadAsStringAsync(); - var errorDetails = errorContent.ToObject(); - - switch (response.StatusCode) { - case System.Net.HttpStatusCode.BadRequest: - _logger.LogError($"Bad request: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.InternalServerError: - _logger.LogError($"Internal server error: {errorDetails?.Message}"); - break; - - default: - _logger.LogError($"Error pulling image: {errorDetails?.Message}"); - break; - } - - response.EnsureSuccessStatusCode(); - } - } - - - public async Task TagImageAsync(string image, string repo, string tag) { - var response = await _httpClient.PostAsync($"/{_apiVersion}/libpod/images/{image}/tag?repo={Uri.EscapeDataString(repo)}&tag={Uri.EscapeDataString(tag)}", null); - - if (response.IsSuccessStatusCode) { - if (response.StatusCode == System.Net.HttpStatusCode.Created) { - _logger.LogInformation("Image tagged successfully."); - } - } - else { - var errorContent = await response.Content.ReadAsStringAsync(); - var errorDetails = errorContent.ToObject(); - - switch (response.StatusCode) { - case System.Net.HttpStatusCode.BadRequest: - _logger.LogError($"Bad parameter in request: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.NotFound: - _logger.LogWarning($"No such image: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.Conflict: - _logger.LogError($"Conflict error in operation: {errorDetails?.Message}"); - break; - - case System.Net.HttpStatusCode.InternalServerError: - _logger.LogError($"Internal server error: {errorDetails?.Message}"); - break; - - default: - _logger.LogError($"Error tagging image: {errorDetails?.Message}"); - break; - } - - response.EnsureSuccessStatusCode(); - } - } - } -} diff --git a/src/PodmanClient/Streaming/IPodmanAttachSession.cs b/src/PodmanClient/Streaming/IPodmanAttachSession.cs new file mode 100644 index 0000000..d3040c1 --- /dev/null +++ b/src/PodmanClient/Streaming/IPodmanAttachSession.cs @@ -0,0 +1,27 @@ +namespace MaksIT.PodmanClientDotNet.Streaming; + +/// +/// Full-duplex attach or exec session over a hijacked Podman HTTP connection. +/// +public interface IPodmanAttachSession : IAsyncDisposable { + /// + /// When true, the connection is a raw TTY stream without multiplex framing. + /// + bool IsRawTerminal { get; } + + /// + /// Reads the next frame (multiplexed) or chunk (raw TTY). + /// Returns null when the stream ends. + /// + Task ReadFrameAsync(CancellationToken cancellationToken = default); + + /// + /// Writes bytes to container stdin (multiplexed framing applied when not raw TTY). + /// + ValueTask WriteStdinAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default); + + /// + /// Signals end of stdin to the container process. + /// + ValueTask CloseWriteAsync(CancellationToken cancellationToken = default); +} diff --git a/src/PodmanClient/Streaming/IPodmanProgressSession.cs b/src/PodmanClient/Streaming/IPodmanProgressSession.cs new file mode 100644 index 0000000..0328555 --- /dev/null +++ b/src/PodmanClient/Streaming/IPodmanProgressSession.cs @@ -0,0 +1,8 @@ +namespace MaksIT.PodmanClientDotNet.Streaming; + +/// +/// Streaming progress events from pull/build and similar NDJSON endpoints. +/// +public interface IPodmanProgressSession : IAsyncDisposable { + IAsyncEnumerable ReadProgressAsync(CancellationToken cancellationToken = default); +} diff --git a/src/PodmanClient/Streaming/PodmanAttachSession.cs b/src/PodmanClient/Streaming/PodmanAttachSession.cs new file mode 100644 index 0000000..2bb954c --- /dev/null +++ b/src/PodmanClient/Streaming/PodmanAttachSession.cs @@ -0,0 +1,83 @@ +using MaksIT.PodmanClientDotNet.Internal; + +namespace MaksIT.PodmanClientDotNet.Streaming; + +internal sealed class PodmanAttachSession : IPodmanAttachSession { + private readonly Stream _stream; + private readonly bool _ownsStream; + private readonly byte[] _headerBuffer = new byte[PodmanMultiplexedProtocol.HeaderSize]; + private readonly byte[] _readBuffer = new byte[81920]; + private bool _writeClosed; + + public bool IsRawTerminal { get; } + + internal PodmanAttachSession(Stream stream, bool isRawTerminal, bool ownsStream = true) { + _stream = stream ?? throw new ArgumentNullException(nameof(stream)); + IsRawTerminal = isRawTerminal; + _ownsStream = ownsStream; + } + + public async Task ReadFrameAsync(CancellationToken cancellationToken = default) { + if (IsRawTerminal) + return await ReadRawAsync(cancellationToken).ConfigureAwait(false); + + var header = await PodmanMultiplexedProtocol.TryReadHeaderAsync(_stream, _headerBuffer, cancellationToken) + .ConfigureAwait(false); + if (header is null) + return null; + + var (type, size) = header.Value; + if (size == 0) + return new PodmanStreamFrame { StreamType = type, Data = [], IsEndOfStream = false }; + + var payload = new byte[size]; + var read = await PodmanMultiplexedProtocol.ReadExactAsync(_stream, payload, cancellationToken) + .ConfigureAwait(false); + if (read < size) + throw new EndOfStreamException("Unexpected end of multiplexed stream while reading payload."); + + return new PodmanStreamFrame { + StreamType = type, + Data = payload, + IsEndOfStream = false, + }; + } + + private async Task ReadRawAsync(CancellationToken cancellationToken) { + var read = await _stream.ReadAsync(_readBuffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + return null; + + return new PodmanStreamFrame { + StreamType = PodmanStreamType.Stdout, + Data = _readBuffer.AsSpan(0, read).ToArray(), + IsEndOfStream = false, + }; + } + + public ValueTask WriteStdinAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) { + if (_writeClosed || data.IsEmpty) + return ValueTask.CompletedTask; + + if (IsRawTerminal) + return new ValueTask(WriteRawAsync(data, cancellationToken)); + + PodmanMultiplexedProtocol.WriteFrame(_stream, PodmanStreamType.Stdin, data.Span); + return ValueTask.CompletedTask; + } + + private Task WriteRawAsync(ReadOnlyMemory data, CancellationToken cancellationToken) => + _stream.WriteAsync(data, cancellationToken).AsTask(); + + public ValueTask CloseWriteAsync(CancellationToken cancellationToken = default) { + _writeClosed = true; + if (_stream is PodmanHijackStream hijack) + hijack.CloseWrite(); + return ValueTask.CompletedTask; + } + + public async ValueTask DisposeAsync() { + if (_ownsStream) + await _stream.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/PodmanClient/Streaming/PodmanMultiplexedProtocol.cs b/src/PodmanClient/Streaming/PodmanMultiplexedProtocol.cs new file mode 100644 index 0000000..48dbf7b --- /dev/null +++ b/src/PodmanClient/Streaming/PodmanMultiplexedProtocol.cs @@ -0,0 +1,42 @@ +using System.Buffers.Binary; + +namespace MaksIT.PodmanClientDotNet.Streaming; + +internal static class PodmanMultiplexedProtocol { + public const int HeaderSize = 8; + + public static void WriteFrame(Stream stream, PodmanStreamType type, ReadOnlySpan payload) { + Span header = stackalloc byte[HeaderSize]; + header[0] = (byte)type; + BinaryPrimitives.WriteUInt32BigEndian(header[4..], (uint)payload.Length); + stream.Write(header); + if (!payload.IsEmpty) + stream.Write(payload); + } + + public static async Task<(PodmanStreamType Type, int PayloadSize)?> TryReadHeaderAsync( + Stream stream, + byte[] headerBuffer, + CancellationToken cancellationToken + ) { + var read = await ReadExactAsync(stream, headerBuffer, cancellationToken).ConfigureAwait(false); + if (read == 0) + return null; + if (read < HeaderSize) + throw new EndOfStreamException("Unexpected end of multiplexed stream while reading header."); + + var size = BinaryPrimitives.ReadUInt32BigEndian(headerBuffer.AsSpan(4)); + return ((PodmanStreamType)headerBuffer[0], (int)size); + } + + public static async Task ReadExactAsync(Stream stream, Memory buffer, CancellationToken cancellationToken) { + var total = 0; + while (total < buffer.Length) { + var read = await stream.ReadAsync(buffer[total..], cancellationToken).ConfigureAwait(false); + if (read == 0) + return total; + total += read; + } + return total; + } +} diff --git a/src/PodmanClient/Streaming/PodmanProgressSession.cs b/src/PodmanClient/Streaming/PodmanProgressSession.cs new file mode 100644 index 0000000..40c1e5c --- /dev/null +++ b/src/PodmanClient/Streaming/PodmanProgressSession.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +using MaksIT.Core.Extensions; + +namespace MaksIT.PodmanClientDotNet.Streaming; + +internal sealed class PodmanProgressSession : IPodmanProgressSession { + private readonly Stream _stream; + private readonly bool _ownsStream; + + internal PodmanProgressSession(Stream stream, bool ownsStream = true) { + _stream = stream ?? throw new ArgumentNullException(nameof(stream)); + _ownsStream = ownsStream; + } + + public async IAsyncEnumerable ReadProgressAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { + using var reader = new StreamReader(_stream, leaveOpen: true); + while (!cancellationToken.IsCancellationRequested) { + var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (line is null) + yield break; + if (string.IsNullOrWhiteSpace(line)) + continue; + + T? item; + try { + item = line.ToObject(); + } + catch (JsonException) { + continue; + } + + if (item is not null) + yield return item; + } + } + + public async ValueTask DisposeAsync() { + if (_ownsStream) + await _stream.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/PodmanClient/Streaming/PodmanStreamFrame.cs b/src/PodmanClient/Streaming/PodmanStreamFrame.cs new file mode 100644 index 0000000..c2f0e61 --- /dev/null +++ b/src/PodmanClient/Streaming/PodmanStreamFrame.cs @@ -0,0 +1,10 @@ +namespace MaksIT.PodmanClientDotNet.Streaming; + +/// +/// A single frame from a multiplexed Podman attach/exec stream. +/// +public sealed class PodmanStreamFrame { + public required PodmanStreamType StreamType { get; init; } + public required byte[] Data { get; init; } + public bool IsEndOfStream { get; init; } +} diff --git a/src/PodmanClient/Streaming/PodmanStreamType.cs b/src/PodmanClient/Streaming/PodmanStreamType.cs new file mode 100644 index 0000000..627afb8 --- /dev/null +++ b/src/PodmanClient/Streaming/PodmanStreamType.cs @@ -0,0 +1,11 @@ +namespace MaksIT.PodmanClientDotNet.Streaming; + +/// +/// Multiplexed attach stream identifier (Docker/Podman raw stream protocol). +/// +public enum PodmanStreamType : byte { + Stdin = 0, + Stdout = 1, + Stderr = 2, + System = 3, +} diff --git a/src/PodmanClientDotNet.Tests/Archives/Tar.cs b/src/PodmanClientDotNet.Tests/Archives/Tar.cs index 8dd6dbe..f00dd20 100644 --- a/src/PodmanClientDotNet.Tests/Archives/Tar.cs +++ b/src/PodmanClientDotNet.Tests/Archives/Tar.cs @@ -1,54 +1,43 @@ -using ICSharpCode.SharpZipLib.Tar; +using System.Text; -namespace MaksIT.PodmanClientDotNet.Tests.Archives -{ - public static class Tar - { - public static void CreateTarFromDirectory(string sourceDirectory, Stream outputStream) - { - using (var tarOutputStream = new TarOutputStream(outputStream)) - { - tarOutputStream.IsStreamOwner = false; - AddDirectoryFilesToTar(tarOutputStream, sourceDirectory, true); - } - } +using ICSharpCode.SharpZipLib.Tar; - static void AddDirectoryFilesToTar(TarOutputStream tarOutputStream, string sourceDirectory, bool recursive, string baseDirectory = null) - { - // If baseDirectory is null, set it to the sourceDirectory to start with - if (baseDirectory == null) - { - baseDirectory = sourceDirectory; - } +namespace MaksIT.PodmanClientDotNet.Tests.Archives; - var directoryInfo = new DirectoryInfo(sourceDirectory); +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); + } - foreach (var fileInfo in directoryInfo.GetFiles()) - { - // Calculate the relative path for the file within the base directory - string relativePath = Path.GetRelativePath(baseDirectory, fileInfo.FullName); + static void AddDirectoryFilesToTar( + TarOutputStream tarOutputStream, + string sourceDirectory, + bool recursive, + string? baseDirectory = null + ) { + baseDirectory ??= sourceDirectory; - // Create tar entry with the relative path - var entry = TarEntry.CreateEntryFromFile(fileInfo.FullName); - entry.Name = relativePath.Replace(Path.DirectorySeparatorChar, '/'); // Use Unix-style path separators - tarOutputStream.PutNextEntry(entry); + var directoryInfo = new DirectoryInfo(sourceDirectory); - using (var fileStream = fileInfo.OpenRead()) - { - fileStream.CopyTo(tarOutputStream); - } + foreach (var fileInfo in directoryInfo.GetFiles()) { + var relativePath = Path.GetRelativePath(baseDirectory, fileInfo.FullName); - tarOutputStream.CloseEntry(); - } + var entry = TarEntry.CreateEntryFromFile(fileInfo.FullName); + entry.Name = relativePath.Replace(Path.DirectorySeparatorChar, '/'); + tarOutputStream.PutNextEntry(entry); - if (recursive) - { - foreach (var subDirectory in directoryInfo.GetDirectories()) - { - // Recurse into subdirectories, passing the base directory - AddDirectoryFilesToTar(tarOutputStream, subDirectory.FullName, true, baseDirectory); - } - } - } + 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/PodmanClientContainersTests.cs b/src/PodmanClientDotNet.Tests/PodmanClientContainersTests.cs index 8b1e257..78e36cf 100644 --- a/src/PodmanClientDotNet.Tests/PodmanClientContainersTests.cs +++ b/src/PodmanClientDotNet.Tests/PodmanClientContainersTests.cs @@ -1,149 +1,126 @@ -using Microsoft.Extensions.Logging; +using MaksIT.PodmanClientDotNet.Tests.Archives; -using MaksIT.PodmanClientDotNet.Tests.Archives; +namespace MaksIT.PodmanClientDotNet.Tests; -namespace MaksIT.PodmanClientDotNet.Tests { - public class PodmanClientContainersTests { - private readonly PodmanClient _client; +[Trait("Category", "Integration")] +public class PodmanClientContainersTests { + private readonly IPodmanClient _client = PodmanClientTestFixture.CreateClient(); - public PodmanClientContainersTests() { - // Initialize the logger - var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); + #region Success Cases + [Fact] + public async Task PodmanClient_ContainerLifecycle_Success() { + string containerName = $"podman-client-test-{Guid.NewGuid()}"; + string image = "alpine:latest"; - // Initialize PodmanClient with real HttpClient - _client = new PodmanClient(logger, "http://wks0002.corp.maks-it.com:8080", 60); - } + await PullImageAsync(image); + var containerId = await CreateContainerAsync(containerName, image); + await StartContainerAsync(containerId); + await StopContainerAsync(containerId); + await ForceDeleteContainerAsync(containerId); + } - #region Success Cases - [Fact] - public async Task PodmanClient_ContainerLifecycle_Success() { - // Arrange - string containerName = $"podman-client-test-{Guid.NewGuid()}"; - string image = "alpine:latest"; + [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(); - // Act & Assert + 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); } - - [Fact] - public async Task CopyFilesToContainer_Success() { - // Arrange - string containerName = $"podman-client-test-{Guid.NewGuid()}"; - string image = "alpine:latest"; - string pathInContainer = "/podman-test-copy"; - - // Create temporary folder with random files - string tempFolderPath = CreateTemporaryFolderWithFiles(); - - try { - // Act - await PullImageAsync(image); - var containerId = await CreateContainerAsync(containerName, image); - await StartContainerAsync(containerId); - - // Archive the folder and copy to container - using (var tarStream = CreateTarStream(tempFolderPath)) { - await CopyToContainerAsync(containerId, tarStream, pathInContainer); - } - - // Stop and delete the container - await StopContainerAsync(containerId); - await ForceDeleteContainerAsync(containerId); - } - finally { - // Cleanup: Delete temporary folder - if (Directory.Exists(tempFolderPath)) { - Directory.Delete(tempFolderPath, true); - } - } + finally { + if (Directory.Exists(tempFolderPath)) + Directory.Delete(tempFolderPath, true); } - #endregion - - #region Helper Methods - private async Task PullImageAsync(string image) { - var exception = await Record.ExceptionAsync(() => _client.PullImageAsync(image)); - Assert.Null(exception); // Expect no exceptions if the pull was successful - } - - private async Task CreateContainerAsync(string containerName, string image) { - var createResponse = await _client.CreateContainerAsync( - name: containerName, - image: image, - command: new List { - "sh", "-c", - "sleep infinity" - }); - Assert.NotNull(createResponse); - Assert.False(string.IsNullOrEmpty(createResponse.Id)); // Ensure a valid container ID is returned - return createResponse.Id; - } - - private async Task StartContainerAsync(string containerId) { - var exception = await Record.ExceptionAsync(() => _client.StartContainerAsync(containerId)); - Assert.Null(exception); // Expect no exceptions if the container was started successfully - } - - private async Task StopContainerAsync(string containerId) { - var exception = await Record.ExceptionAsync(() => _client.StopContainerAsync(containerId)); - Assert.Null(exception); // Expect no exceptions if the container was stopped successfully - } - - private async Task ForceDeleteContainerAsync(string containerId) { - var exception = await Record.ExceptionAsync(() => _client.ForceDeleteContainerAsync(containerId)); - Assert.Null(exception); // Expect no exceptions if the container was deleted successfully - } - - private async Task CopyToContainerAsync(string containerId, Stream tarStream, string path) { - var exception = await Record.ExceptionAsync(() => _client.ExtractArchiveToContainerAsync(containerId, tarStream, path)); - Assert.Null(exception); // Expect no exceptions if the copy was successful - } - - private string CreateTemporaryFolderWithFiles() { - string tempFolder = Path.Combine(Path.GetTempPath(), $"podman-test-{Guid.NewGuid()}"); - Directory.CreateDirectory(tempFolder); - - // Create some random files - for (int i = 0; i < 5; i++) { - File.WriteAllText(Path.Combine(tempFolder, $"test-file-{i}.txt"), $"This is test file {i}"); - } - - return tempFolder; - } - - private Stream CreateTarStream(string folderPath) { - var memoryStream = new MemoryStream(); - Tar.CreateTarFromDirectory(folderPath, memoryStream); - memoryStream.Seek(0, SeekOrigin.Begin); // Reset the stream position for reading - return memoryStream; - } - #endregion - - #region Fail Cases - [Fact] - public async Task StartContainerAsync_Should_HandleErrors() { - string invalidContainerId = "invalid-container-id"; - var exception = await Record.ExceptionAsync(() => _client.StartContainerAsync(invalidContainerId)); - Assert.NotNull(exception); // Expect an exception due to invalid container ID - } - - [Fact] - public async Task StopContainerAsync_Should_HandleErrors() { - string invalidContainerId = "invalid-container-id"; - var exception = await Record.ExceptionAsync(() => _client.StopContainerAsync(invalidContainerId)); - Assert.NotNull(exception); // Expect an exception due to invalid container ID - } - - [Fact] - public async Task ForceDeleteContainerAsync_Should_HandleErrors() { - string invalidContainerId = "invalid-container-id"; - var exception = await Record.ExceptionAsync(() => _client.ForceDeleteContainerAsync(invalidContainerId)); - Assert.NotNull(exception); // Expect an exception due to invalid container ID - } - #endregion } + #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/PodmanClientDotNet.Tests.csproj b/src/PodmanClientDotNet.Tests/PodmanClientDotNet.Tests.csproj index 06fff20..bd3f80f 100644 --- a/src/PodmanClientDotNet.Tests/PodmanClientDotNet.Tests.csproj +++ b/src/PodmanClientDotNet.Tests/PodmanClientDotNet.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable enable @@ -10,18 +10,18 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - - + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/PodmanClientDotNet.Tests/PodmanClientExecTests.cs b/src/PodmanClientDotNet.Tests/PodmanClientExecTests.cs index dc0c396..4650e11 100644 --- a/src/PodmanClientDotNet.Tests/PodmanClientExecTests.cs +++ b/src/PodmanClientDotNet.Tests/PodmanClientExecTests.cs @@ -1,193 +1,127 @@ -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Xunit; +namespace MaksIT.PodmanClientDotNet.Tests; -namespace MaksIT.PodmanClientDotNet.Tests { - public class PodmanClientExecTests { - private readonly PodmanClient _client; +[Trait("Category", "Integration")] +public class PodmanClientExecTests { + private readonly IPodmanClient _client = PodmanClientTestFixture.CreateClient(); - public PodmanClientExecTests() { - var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); + #region Success Cases + [Fact] + public async Task Full_ContainerLifecycle_With_Exec_Should_Succeed() { + string containerName = $"podman-client-test-{Guid.NewGuid()}"; + string image = "alpine:latest"; - _client = new PodmanClient(logger, "http://wks0002.corp.maks-it.com:8080", 60); - } + await PullImageAsync(image); + var containerId = await CreateContainerAsync(containerName, image); + await StartContainerAsync(containerId); - #region Success Cases + var execId = await CreateExecAsync(containerName, new[] { "apk", "add", "--no-cache", "curl" }); + await StartExecAsync(execId); - [Fact] - public async Task Full_ContainerLifecycle_With_Exec_Should_Succeed() { - // Arrange - string containerName = $"podman-client-test-{Guid.NewGuid()}"; - string image = "alpine:latest"; - - // Act & Assert - // 1. Pull the image - await PullImageAsync(image); - - // 2. Create the container with sleep infinity command - var containerId = await CreateContainerAsync(containerName, image); - - // 3. Start the container - await StartContainerAsync(containerId); - - // 4. Execute a command in the container to install a package (e.g., "apk add curl") - var execId = await CreateExecAsync(containerName, new[] { "apk", "add", "--no-cache", "curl" }); - await StartExecAsync(execId); - - // 5. Stop the container - await StopContainerAsync(containerId); - - // 6. Delete the container - await ForceDeleteContainerAsync(containerId); - } - - #endregion - - #region Helper Methods - - private async Task PullImageAsync(string image) { - var exception = await Record.ExceptionAsync(() => _client.PullImageAsync(image)); - Assert.Null(exception); // Expect no exceptions if the pull was successful - } - - private async Task CreateContainerAsync(string containerName, string image) { - var createResponse = await _client.CreateContainerAsync( - name: containerName, - image: image, - command: new List { - "sh", "-c", - "sleep infinity" - }); - Assert.NotNull(createResponse); - Assert.False(string.IsNullOrEmpty(createResponse.Id)); // Ensure a valid container ID is returned - return createResponse.Id; - } - - private async Task StartContainerAsync(string containerId) { - var exception = await Record.ExceptionAsync(() => _client.StartContainerAsync(containerId)); - Assert.Null(exception); // Expect no exceptions if the container was started successfully - } - - private async Task CreateExecAsync(string containerName, string[] cmd) { - var execResponse = await _client.CreateExecAsync(containerName, cmd); - Assert.NotNull(execResponse); - Assert.False(string.IsNullOrEmpty(execResponse.Id)); // Ensure a valid exec ID is returned - return execResponse.Id; - } - - private async Task StartExecAsync(string execId) { - var exception = await Record.ExceptionAsync(() => _client.StartExecAsync(execId)); - Assert.Null(exception); // Expect no exceptions if the exec command was started successfully - } - - private async Task StopContainerAsync(string containerId) { - var exception = await Record.ExceptionAsync(() => _client.StopContainerAsync(containerId)); - Assert.Null(exception); // Expect no exceptions if the container was stopped successfully - } - - private async Task ForceDeleteContainerAsync(string containerId) { - var exception = await Record.ExceptionAsync(() => _client.ForceDeleteContainerAsync(containerId)); - Assert.Null(exception); // Expect no exceptions if the container was deleted successfully - } - - #endregion - - #region Fail Cases - - [Fact] - public async Task PullImageAsync_Should_HandleErrors() { - // Arrange - string invalidImageReference = "invalidimage:latest"; // Intentionally wrong image - - // Act - var exception = await Record.ExceptionAsync(() => _client.PullImageAsync(invalidImageReference)); - - // Assert - Assert.NotNull(exception); - Assert.IsType(exception); // Ensure it's the expected type - } - - [Fact] - public async Task CreateContainerAsync_Should_HandleErrors() { - // Arrange - string invalidImage = "invalidimage:latest"; // Intentionally wrong image - string containerName = "test-container"; - - // Act - var exception = await Record.ExceptionAsync(() => _client.CreateContainerAsync(containerName, invalidImage, new List { "sh", "-c", "sleep infinity" })); - - // Assert - Assert.NotNull(exception); - Assert.IsType(exception); // Ensure it's the expected type - } - - [Fact] - public async Task StartContainerAsync_Should_HandleErrors() { - // Arrange - string invalidContainerId = "invalid-container-id"; // Intentionally wrong container ID - - // Act - var exception = await Record.ExceptionAsync(() => _client.StartContainerAsync(invalidContainerId)); - - // Assert - Assert.NotNull(exception); - Assert.IsType(exception); // Ensure it's the expected type - } - - [Fact] - public async Task CreateExecAsync_Should_HandleErrors() { - // Arrange - string containerName = "invalid-container"; // Intentionally wrong container name - var cmd = new[] { "apk", "add", "--no-cache", "curl" }; - - // Act - var exception = await Record.ExceptionAsync(() => _client.CreateExecAsync(containerName, cmd)); - - // Assert - Assert.NotNull(exception); - Assert.IsType(exception); // Ensure it's the expected type - } - - [Fact] - public async Task StartExecAsync_Should_HandleErrors() { - // Arrange - string invalidExecId = "invalid-exec-id"; // Intentionally wrong exec ID - - // Act - var exception = await Record.ExceptionAsync(() => _client.StartExecAsync(invalidExecId)); - - // Assert - Assert.NotNull(exception); - Assert.IsType(exception); // Ensure it's the expected type - } - - [Fact] - public async Task StopContainerAsync_Should_HandleErrors() { - // Arrange - string invalidContainerId = "invalid-container-id"; // Intentionally wrong container ID - - // Act - var exception = await Record.ExceptionAsync(() => _client.StopContainerAsync(invalidContainerId)); - - // Assert - Assert.NotNull(exception); - Assert.IsType(exception); // Ensure it's the expected type - } - - [Fact] - public async Task ForceDeleteContainerAsync_Should_HandleErrors() { - // Arrange - string invalidContainerId = "invalid-container-id"; // Intentionally wrong container ID - - // Act - var exception = await Record.ExceptionAsync(() => _client.ForceDeleteContainerAsync(invalidContainerId)); - - // Assert - Assert.NotNull(exception); - Assert.IsType(exception); // Ensure it's the expected type - } - - #endregion + 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 index 1a87e41..5751091 100644 --- a/src/PodmanClientDotNet.Tests/PodmanClientImagesTests.cs +++ b/src/PodmanClientDotNet.Tests/PodmanClientImagesTests.cs @@ -1,87 +1,38 @@ - -using Microsoft.Extensions.Logging; +namespace MaksIT.PodmanClientDotNet.Tests; - -namespace MaksIT.PodmanClientDotNet.Tests; +[Trait("Category", "Integration")] public class PodmanClientImagesTests { - private readonly PodmanClient _client; - - public PodmanClientImagesTests() { - // Initialize the logger - var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); - var logger = loggerFactory.CreateLogger(); - - // Initialize PodmanClient with real HttpClient - _client = new PodmanClient(logger, "http://wks0002.corp.maks-it.com:8080", 60); - } + private readonly IPodmanClient _client = PodmanClientTestFixture.CreateClient(); #region Success Cases - [Fact] public async Task PodmanClient_IntegrationTests() { - // Test 1: Pull Image - Success await PullImageAsync_Should_Succeed(); - - // Test 2: Tag Image - Success await TagImageAsync_Should_Succeed(); } - #endregion - - #region Helper Methods private async Task PullImageAsync_Should_Succeed() { - // Arrange - string imageReference = "alpine:latest"; // Example image - - // Act - var exception = await Record.ExceptionAsync(() => _client.PullImageAsync(imageReference)); - - // Assert - Assert.Null(exception); // Expect no exceptions if the pull was successful + var result = await _client.PullImageAsync("alpine:latest"); + PodmanClientTestFixture.AssertSuccess(result); } private async Task TagImageAsync_Should_Succeed() { - // Arrange - string image = "alpine:latest"; // Example image - string repo = "myrepo"; - string tag = "v1"; - - // Act - var exception = await Record.ExceptionAsync(() => _client.TagImageAsync(image, repo, tag)); - - // Assert - Assert.Null(exception); // Expect no exceptions if the tagging was successful + var result = await _client.TagImageAsync("alpine:latest", "myrepo", "v1"); + PodmanClientTestFixture.AssertSuccess(result); } #endregion #region Fail Cases [Fact] public async Task PodmanClient_PullImage_Errors() { - - // Arrange - string imageReference = "dghdfdghmhgn:latest"; // Intentionally wrong image - - // Act - var exception = await Record.ExceptionAsync(() => _client.PullImageAsync(imageReference)); - - // Assert - Assert.NotNull(exception); // Expect an exception due to nonexistent image - Assert.IsType(exception); // Ensure it's the expected type + var result = await _client.PullImageAsync("dghdfdghmhgn:latest"); + PodmanClientTestFixture.AssertFailure(result); } [Fact] public async Task PodmanClient_TagImage_Errors() { - // Arrange - string image = "dghdfdghmhgn:latest"; // Intentionally wrong image - string repo = "myrepo"; - string tag = "v1"; - - // Act - var exception = await Record.ExceptionAsync(() => _client.TagImageAsync(image, repo, tag)); - - // Assert - Assert.NotNull(exception); // Expect an exception due to nonexistent image - Assert.IsType(exception); // Ensure it's the expected type + 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 new file mode 100644 index 0000000..9069477 --- /dev/null +++ b/src/PodmanClientDotNet.Tests/PodmanClientStreamingIntegrationTests.cs @@ -0,0 +1,128 @@ +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 new file mode 100644 index 0000000..ffb8ff0 --- /dev/null +++ b/src/PodmanClientDotNet.Tests/PodmanClientTestFixture.cs @@ -0,0 +1,42 @@ +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/PodmanAttachSessionCloseWriteTests.cs b/src/PodmanClientDotNet.Tests/Streaming/PodmanAttachSessionCloseWriteTests.cs new file mode 100644 index 0000000..ffd7120 --- /dev/null +++ b/src/PodmanClientDotNet.Tests/Streaming/PodmanAttachSessionCloseWriteTests.cs @@ -0,0 +1,13 @@ +using MaksIT.PodmanClientDotNet.Streaming; + +namespace MaksIT.PodmanClientDotNet.Tests.Streaming; + +public class PodmanAttachSessionCloseWriteTests { + [Fact] + public async Task CloseWriteAsync_DoesNotThrowForNonHijackStream() { + using var stream = new MemoryStream(); + await using var session = new PodmanAttachSession(stream, isRawTerminal: false, ownsStream: false); + await session.CloseWriteAsync(TestContext.Current.CancellationToken); + await session.WriteStdinAsync(ReadOnlyMemory.Empty, TestContext.Current.CancellationToken); + } +} diff --git a/src/PodmanClientDotNet.Tests/Streaming/PodmanAttachSessionTests.cs b/src/PodmanClientDotNet.Tests/Streaming/PodmanAttachSessionTests.cs new file mode 100644 index 0000000..732c643 --- /dev/null +++ b/src/PodmanClientDotNet.Tests/Streaming/PodmanAttachSessionTests.cs @@ -0,0 +1,51 @@ +using System.Text; + +using MaksIT.PodmanClientDotNet.Streaming; + +namespace MaksIT.PodmanClientDotNet.Tests.Streaming; + +public class PodmanAttachSessionTests { + [Fact] + public async Task ReadFrameAsync_ReadsMultiplexedStdout() { + using var stream = new MemoryStream(); + PodmanMultiplexedProtocol.WriteFrame(stream, PodmanStreamType.Stdout, "hello"u8.ToArray()); + stream.Position = 0; + + await using var session = new PodmanAttachSession(stream, isRawTerminal: false, ownsStream: false); + var frame = await session.ReadFrameAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(frame); + Assert.Equal(PodmanStreamType.Stdout, frame!.StreamType); + Assert.Equal("hello", Encoding.UTF8.GetString(frame.Data)); + } + + [Fact] + public async Task WriteStdinAsync_WritesMultiplexedFrame() { + using var stream = new MemoryStream(); + await using var session = new PodmanAttachSession(stream, isRawTerminal: false, ownsStream: false); + + await session.WriteStdinAsync("abc"u8.ToArray(), TestContext.Current.CancellationToken); + + stream.Position = 0; + var header = new byte[8]; + stream.ReadExactly(header); + Assert.Equal((byte)PodmanStreamType.Stdin, header[0]); + Assert.Equal(3u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(4))); + var payload = new byte[3]; + stream.ReadExactly(payload); + Assert.Equal("abc", Encoding.UTF8.GetString(payload)); + } + + [Fact] + public async Task ReadFrameAsync_RawTerminal_ReturnsChunk() { + var payload = Encoding.UTF8.GetBytes("tty-data"); + using var stream = new MemoryStream(payload); + await using var session = new PodmanAttachSession(stream, isRawTerminal: true, ownsStream: false); + + var cancellationToken = TestContext.Current.CancellationToken; + var frame = await session.ReadFrameAsync(cancellationToken); + Assert.NotNull(frame); + Assert.Equal("tty-data", Encoding.UTF8.GetString(frame!.Data)); + Assert.Null(await session.ReadFrameAsync(cancellationToken)); + } +} diff --git a/src/PodmanClientDotNet.Tests/Streaming/PodmanHijackConnectionTests.cs b/src/PodmanClientDotNet.Tests/Streaming/PodmanHijackConnectionTests.cs new file mode 100644 index 0000000..5e61326 --- /dev/null +++ b/src/PodmanClientDotNet.Tests/Streaming/PodmanHijackConnectionTests.cs @@ -0,0 +1,49 @@ +using System.Buffers.Binary; +using System.Text; + +using MaksIT.PodmanClientDotNet.Internal; +using MaksIT.PodmanClientDotNet.Streaming; + +namespace MaksIT.PodmanClientDotNet.Tests.Streaming; + +public class PodmanHijackConnectionTests { + [Fact] + public async Task ConnectAsync_ReadsMultiplexedPayloadFromMockServer() { + await using var server = new PodmanHijackMockServer(); + using var payloadStream = new MemoryStream(); + PodmanMultiplexedProtocol.WriteFrame(payloadStream, PodmanStreamType.Stdout, "ok"u8.ToArray()); + var payload = payloadStream.ToArray(); + + var cancellationToken = TestContext.Current.CancellationToken; + var acceptTask = server.AcceptAndSendHijackResponseAsync(payload, cancellationToken); + + await using var hijack = await PodmanHijackConnection.ConnectAsync( + new Uri($"http://127.0.0.1:{server.Port}"), + "v1.41", + HttpMethod.Post, + "/libpod/containers/test/attach", + "?stdin=1&stdout=1&stderr=1&stream=1", + requestBody: null, + cancellationToken + ); + + await acceptTask; + + await using var session = new PodmanAttachSession(hijack, isRawTerminal: false); + var frame = await session.ReadFrameAsync(cancellationToken); + Assert.NotNull(frame); + Assert.Equal(PodmanStreamType.Stdout, frame!.StreamType); + Assert.Equal("ok", Encoding.UTF8.GetString(frame.Data)); + } + + [Fact] + public void WriteFrame_StdinType_MatchesProtocol() { + using var stream = new MemoryStream(); + PodmanMultiplexedProtocol.WriteFrame(stream, PodmanStreamType.Stdin, [0x41]); + stream.Position = 0; + var header = new byte[8]; + stream.ReadExactly(header); + Assert.Equal(0, header[0]); + Assert.Equal(1u, BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(4))); + } +} diff --git a/src/PodmanClientDotNet.Tests/Streaming/PodmanHijackMockServer.cs b/src/PodmanClientDotNet.Tests/Streaming/PodmanHijackMockServer.cs new file mode 100644 index 0000000..bd32206 --- /dev/null +++ b/src/PodmanClientDotNet.Tests/Streaming/PodmanHijackMockServer.cs @@ -0,0 +1,44 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace MaksIT.PodmanClientDotNet.Tests.Streaming; + +internal sealed class PodmanHijackMockServer : IAsyncDisposable { + private readonly TcpListener _listener; + private TcpClient? _client; + + public int Port { get; } + + public PodmanHijackMockServer() { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + } + + public async Task AcceptAndSendHijackResponseAsync(byte[] multiplexedPayload, CancellationToken cancellationToken = default) { + _client = await _listener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); + var stream = _client.GetStream(); + using var reader = new StreamReader(stream, Encoding.ASCII, leaveOpen: true); + + _ = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + while (true) { + var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrEmpty(line)) + break; + } + + var response = + "HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n"; + var responseBytes = Encoding.ASCII.GetBytes(response); + await stream.WriteAsync(responseBytes, cancellationToken).ConfigureAwait(false); + if (multiplexedPayload.Length > 0) + await stream.WriteAsync(multiplexedPayload, cancellationToken).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() { + _client?.Dispose(); + _listener.Stop(); + await Task.CompletedTask.ConfigureAwait(false); + } +} diff --git a/src/PodmanClientDotNet.Tests/Streaming/PodmanMultiplexedProtocolTests.cs b/src/PodmanClientDotNet.Tests/Streaming/PodmanMultiplexedProtocolTests.cs new file mode 100644 index 0000000..a6067ee --- /dev/null +++ b/src/PodmanClientDotNet.Tests/Streaming/PodmanMultiplexedProtocolTests.cs @@ -0,0 +1,38 @@ +using System.Buffers.Binary; +using System.Text; + +using MaksIT.PodmanClientDotNet.Streaming; + +namespace MaksIT.PodmanClientDotNet.Tests.Streaming; + +public class PodmanMultiplexedProtocolTests { + [Fact] + public void WriteFrame_WritesHeaderAndPayload() { + using var stream = new MemoryStream(); + var payload = "hello"u8.ToArray(); + + PodmanMultiplexedProtocol.WriteFrame(stream, PodmanStreamType.Stdout, payload); + + stream.Position = 0; + var header = new byte[8]; + Assert.Equal(8, stream.Read(header)); + Assert.Equal((byte)PodmanStreamType.Stdout, header[0]); + Assert.Equal(5u, BinaryPrimitives.ReadUInt32BigEndian(header.AsSpan(4))); + + var readPayload = new byte[5]; + Assert.Equal(5, stream.Read(readPayload)); + Assert.Equal("hello", Encoding.UTF8.GetString(readPayload)); + } + + [Fact] + public async Task TryReadHeaderAsync_ReadsStdoutFrame() { + using var stream = new MemoryStream(); + PodmanMultiplexedProtocol.WriteFrame(stream, PodmanStreamType.Stderr, "err"u8.ToArray()); + stream.Position = 0; + + var header = await PodmanMultiplexedProtocol.TryReadHeaderAsync(stream, new byte[8], CancellationToken.None); + Assert.NotNull(header); + Assert.Equal(PodmanStreamType.Stderr, header.Value.Type); + Assert.Equal(3, header.Value.PayloadSize); + } +} diff --git a/src/PodmanClientDotNet.Tests/Streaming/PodmanNdjsonStreamsTests.cs b/src/PodmanClientDotNet.Tests/Streaming/PodmanNdjsonStreamsTests.cs new file mode 100644 index 0000000..25da487 --- /dev/null +++ b/src/PodmanClientDotNet.Tests/Streaming/PodmanNdjsonStreamsTests.cs @@ -0,0 +1,56 @@ +using System.Text; + +using MaksIT.PodmanClientDotNet.Dtos.Build; +using MaksIT.PodmanClientDotNet.Internal; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MaksIT.PodmanClientDotNet.Tests.Streaming; + +public class PodmanNdjsonStreamsTests { + private static readonly ILogger Logger = NullLogger.Instance; + + [Fact] + public async Task DrainPullOrPushAsync_SucceedsWhenNoErrorLines() { + var json = "{\"status\":\"Pulling fs layer\"}\n{\"status\":\"Download complete\"}\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var result = await PodmanNdjsonStreams.DrainPullOrPushAsync(stream, Logger, "Pull image", TestContext.Current.CancellationToken); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task DrainPullOrPushAsync_FailsOnErrorLine() { + var json = "{\"status\":\"Pulling\"}\n{\"error\":\"denied: access forbidden\"}\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var result = await PodmanNdjsonStreams.DrainPullOrPushAsync(stream, Logger, "Pull image", TestContext.Current.CancellationToken); + + Assert.False(result.IsSuccess); + Assert.Contains("denied", result.Messages[0], StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DrainBuildAsync_ReturnsIdFromProgressLines() { + var json = "{\"stream\":\"Step 1/1\"}\n{\"id\":\"sha256:abc123\"}\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var result = await PodmanNdjsonStreams.DrainBuildAsync(stream, Logger, TestContext.Current.CancellationToken); + + Assert.True(result.IsSuccess); + Assert.Equal("sha256:abc123", result.Value!.Id); + } + + [Fact] + public async Task DrainBuildAsync_FailsOnErrorField() { + var json = "{\"stream\":\"Step 1/1\"}\n{\"error\":\"Dockerfile parse error\"}\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + var result = await PodmanNdjsonStreams.DrainBuildAsync(stream, Logger, TestContext.Current.CancellationToken); + + Assert.False(result.IsSuccess); + Assert.Contains("Dockerfile", result.Messages[0], StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/PodmanClientDotNet.Tests/Streaming/PodmanProgressSessionTests.cs b/src/PodmanClientDotNet.Tests/Streaming/PodmanProgressSessionTests.cs new file mode 100644 index 0000000..3c0d88c --- /dev/null +++ b/src/PodmanClientDotNet.Tests/Streaming/PodmanProgressSessionTests.cs @@ -0,0 +1,23 @@ +using System.Text; + +using MaksIT.PodmanClientDotNet.Dtos.Image; +using MaksIT.PodmanClientDotNet.Streaming; + +namespace MaksIT.PodmanClientDotNet.Tests.Streaming; + +public class PodmanProgressSessionTests { + [Fact] + public async Task ReadProgressAsync_ParsesNdjsonLines() { + var json = "{\"status\":\"Pulling fs layer\"}\n{\"id\":\"abc\"}\n"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + await using var session = new PodmanProgressSession(stream, ownsStream: false); + + var items = new List(); + await foreach (var item in session.ReadProgressAsync(TestContext.Current.CancellationToken)) + items.Add(item); + + Assert.Equal(2, items.Count); + Assert.Equal("Pulling fs layer", items[0].Status); + Assert.Equal("abc", items[1].Id); + } +} diff --git a/src/PodmanClientDotNet.sln b/src/PodmanClientDotNet.sln deleted file mode 100644 index b14cb33..0000000 --- a/src/PodmanClientDotNet.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.9.34902.65 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PodmanClientDotNet", "PodmanClient\PodmanClientDotNet.csproj", "{0833C90F-6BF3-40E4-A035-B6D6C81DB9D7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PodmanClientDotNet.Tests", "PodmanClientDotNet.Tests\PodmanClientDotNet.Tests.csproj", "{657C39AC-BF63-4678-9A35-A782FE9D4D7E}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {0833C90F-6BF3-40E4-A035-B6D6C81DB9D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0833C90F-6BF3-40E4-A035-B6D6C81DB9D7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0833C90F-6BF3-40E4-A035-B6D6C81DB9D7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0833C90F-6BF3-40E4-A035-B6D6C81DB9D7}.Release|Any CPU.Build.0 = Release|Any CPU - {657C39AC-BF63-4678-9A35-A782FE9D4D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {657C39AC-BF63-4678-9A35-A782FE9D4D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {657C39AC-BF63-4678-9A35-A782FE9D4D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {657C39AC-BF63-4678-9A35-A782FE9D4D7E}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {CFC10A81-CC42-4585-B549-1C2214AC18DA} - EndGlobalSection -EndGlobal diff --git a/src/PodmanClientDotNet.slnx b/src/PodmanClientDotNet.slnx new file mode 100644 index 0000000..b079a07 --- /dev/null +++ b/src/PodmanClientDotNet.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/src/Release-NuGetPackage.bat b/src/Release-NuGetPackage.bat deleted file mode 100644 index ba9cefe..0000000 --- a/src/Release-NuGetPackage.bat +++ /dev/null @@ -1,7 +0,0 @@ -@echo off - -REM Change directory to the location of the script -cd /d %~dp0 - -REM Invoke the PowerShell script (Release-NuGetPackage.ps1) in the same directory -powershell -ExecutionPolicy Bypass -File "%~dp0Release-NuGetPackage.ps1" diff --git a/src/Release-NuGetPackage.ps1 b/src/Release-NuGetPackage.ps1 deleted file mode 100644 index bf48625..0000000 --- a/src/Release-NuGetPackage.ps1 +++ /dev/null @@ -1,46 +0,0 @@ -# Retrieve the API key from the environment variable -$apiKey = $env:NUGET_MAKS_IT -if (-not $apiKey) { - Write-Host "Error: API key not found in environment variable NUGET_MAKS_IT." - exit 1 -} - -# NuGet source -$nugetSource = "https://api.nuget.org/v3/index.json" - -# Define paths -$solutionDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$projectDir = "$solutionDir\PodmanClient" -$outputDir = "$projectDir\bin\Release" - -# Clean previous builds -Write-Host "Cleaning previous builds..." -dotnet clean $projectDir -c Release - -# Build the project -Write-Host "Building the project..." -dotnet build $projectDir -c Release - -# Pack the NuGet package -Write-Host "Packing the project..." -dotnet pack $projectDir -c Release --no-build - -# Look for the .nupkg file -$packageFile = Get-ChildItem -Path $outputDir -Filter "*.nupkg" -Recurse | Sort-Object LastWriteTime -Descending | Select-Object -First 1 - -if ($packageFile) { - Write-Host "Package created successfully: $($packageFile.FullName)" - - # Push the package to NuGet - Write-Host "Pushing the package to NuGet..." - dotnet nuget push $packageFile.FullName -k $apiKey -s $nugetSource --skip-duplicate - - if ($LASTEXITCODE -eq 0) { - Write-Host "Package pushed successfully." - } else { - Write-Host "Failed to push the package." - } -} else { - Write-Host "Package creation failed. No .nupkg file found." - exit 1 -} diff --git a/src/Release-NuGetPackage.sh b/src/Release-NuGetPackage.sh deleted file mode 100644 index abb8af4..0000000 --- a/src/Release-NuGetPackage.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/sh - -# Retrieve the API key from the environment variable -apiKey=$NUGET_MAKS_IT -if [ -z "$apiKey" ]; then - echo "Error: API key not found in environment variable NUGET_MAKS_IT." - exit 1 -fi - -# NuGet source -nugetSource="https://api.nuget.org/v3/index.json" - -# Define paths -scriptDir=$(dirname "$0") -solutionDir=$(realpath "$scriptDir") -projectDir="$solutionDir/PodmanClient" -outputDir="$projectDir/bin/Release" - -# Clean previous builds -echo "Cleaning previous builds..." -dotnet clean "$projectDir" -c Release - -# Build the project -echo "Building the project..." -dotnet build "$projectDir" -c Release - -# Pack the NuGet package -echo "Packing the project..." -dotnet pack "$projectDir" -c Release --no-build - -# Look for the .nupkg file -packageFile=$(find "$outputDir" -name "*.nupkg" -print0 | xargs -0 ls -t | head -n 1) - -if [ -n "$packageFile" ]; then - echo "Package created successfully: $packageFile" - - # Push the package to NuGet - echo "Pushing the package to NuGet..." - dotnet nuget push "$packageFile" -k "$apiKey" -s "$nugetSource" --skip-duplicate - - if [ $? -eq 0 ]; then - echo "Package pushed successfully." - else - echo "Failed to push the package." - fi -else - echo "Package creation failed. No .nupkg file found." - exit 1 -fi diff --git a/utils/Force-AmendTaggedCommit.bat b/utils/Force-AmendTaggedCommit.bat new file mode 100644 index 0000000..67b7d76 --- /dev/null +++ b/utils/Force-AmendTaggedCommit.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Force-AmendTaggedCommit\Force-AmendTaggedCommit.ps1" %* +pause diff --git a/utils/Invoke-ReleasePackage.bat b/utils/Invoke-ReleasePackage.bat new file mode 100644 index 0000000..85d776a --- /dev/null +++ b/utils/Invoke-ReleasePackage.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %* +pause diff --git a/utils/Invoke-TestEngine.bat b/utils/Invoke-TestEngine.bat new file mode 100644 index 0000000..0cfd13f --- /dev/null +++ b/utils/Invoke-TestEngine.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\test\Invoke-TestEngine.ps1" %* +pause diff --git a/utils/Update-RepoUtils.bat b/utils/Update-RepoUtils.bat new file mode 100644 index 0000000..048e3fb --- /dev/null +++ b/utils/Update-RepoUtils.bat @@ -0,0 +1,3 @@ +@echo off +pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Update-RepoUtils\Update-RepoUtils.ps1" %* +pause diff --git a/utils/engines/release/Invoke-ReleasePackage.ps1 b/utils/engines/release/Invoke-ReleasePackage.ps1 new file mode 100644 index 0000000..caf880e --- /dev/null +++ b/utils/engines/release/Invoke-ReleasePackage.ps1 @@ -0,0 +1,80 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Plugin-driven release engine entry script. +#> + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path + +. (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1') +Import-EngineModules -Engine Release + +$settings = Get-ScriptSettings -ScriptDir $scriptDir +$configuredPlugins = Get-ConfiguredPlugins -Settings $settings + +Write-Log -Level 'STEP' -Message '==================================================' +Write-Log -Level 'STEP' -Message 'RELEASE ENGINE' +Write-Log -Level 'STEP' -Message '==================================================' + +$plugins = $configuredPlugins +$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -SrcDir $srcDir -Settings $settings +Write-Log -Level 'OK' -Message 'All pre-flight checks passed!' +$sharedPluginSettings = $engineContext + +$releaseStageInitialized = $false +$releaseHadPluginFailures = $false + +if ($plugins.Count -eq 0) { + Write-Log -Level 'WARN' -Message 'No plugins configured in scriptSettings.json.' +} +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) { + $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 + if (-not $pluginSucceeded) { + $releaseHadPluginFailures = $true + break + } + } +} + +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 'OK' -Message '==================================================' +if ($releaseHadPluginFailures) { + Write-Log -Level 'ERROR' -Message 'RELEASE FAILED' +} +elseif ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins) { + Write-Log -Level 'OK' -Message 'RUN COMPLETE (publish skipped by ReleasePublishGuard)' +} +elseif ($engineContext.isNonReleaseBranch) { + Write-Log -Level 'OK' -Message 'NON-RELEASE RUN COMPLETE' +} +else { + Write-Log -Level 'OK' -Message 'RELEASE COMPLETE' +} +Write-Log -Level 'OK' -Message '==================================================' + +if ($engineContext.isNonReleaseBranch -and -not ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins)) { + $preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext + Write-Log -Level 'INFO' -Message "For publish, use an allowed branch (see ReleasePublishGuard.branches), e.g. '$preferredReleaseBranch', and satisfy the guard requirements." +} + +if ($releaseHadPluginFailures) { + exit 1 +} diff --git a/utils/engines/release/custom/.gitkeep b/utils/engines/release/custom/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/utils/engines/release/custom/.gitkeep @@ -0,0 +1 @@ + diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json new file mode 100644 index 0000000..6b52503 --- /dev/null +++ b/utils/engines/release/scriptSettings.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Release Package Script Settings", + "description": "Invoke-ReleasePackage.ps1 plugin settings for PodmanClient.DotNet.", + "plugins": [ + { + "name": "DotNetReleaseVersion", + "stageLabel": "build", + "enabled": true, + "projectFiles": [ + "..\\..\\..\\src\\PodmanClient\\PodmanClientDotNet.csproj" + ] + }, + { + "name": "DotNetTest", + "stageLabel": "test", + "enabled": true, + "project": "..\\..\\..\\src\\PodmanClientDotNet.Tests", + "resultsDir": "..\\..\\..\\testResults" + }, + { + "name": "QualityGate", + "stageLabel": "qualityGate", + "enabled": true, + "coverageThreshold": 0, + "failOnVulnerabilities": true, + "projectFiles": [ + "..\\..\\..\\src\\PodmanClient\\PodmanClientDotNet.csproj" + ] + }, + { + "name": "DotNetPack", + "stageLabel": "build", + "enabled": true, + "projectFiles": [ + "..\\..\\..\\src\\PodmanClient\\PodmanClientDotNet.csproj" + ], + "artifactsDir": "..\\..\\..\\release" + }, + { + "name": "DotNetCreateArchive", + "stageLabel": "build", + "enabled": true, + "zipNamePattern": "podman-client-dotnet-{version}.zip" + }, + { + "name": "ReleasePublishGuard", + "stageLabel": "release", + "enabled": true, + "branches": [ + "main" + ], + "requireExactTagOnHead": true, + "tagVersionMustMatchDotNetRelease": true, + "whenRequirementsNotMet": "skip", + "requireCleanWorkingTree": false, + "ensureTagOnRemote": true, + "remoteName": "origin" + }, + { + "name": "GitHub", + "stageLabel": "release", + "enabled": true, + "githubToken": "GITHUB_MAKS_IT_COM", + "repository": "https://github.com/MAKS-IT-COM/podman-client-dotnet", + "releaseNotesFile": "..\\..\\..\\CHANGELOG.md", + "releaseTitlePattern": "Release {version}" + }, + { + "name": "DotNetNuGet", + "stageLabel": "release", + "enabled": true, + "nugetApiKey": "NUGET_MAKS_IT", + "source": "https://api.nuget.org/v3/index.json" + }, + { + "name": "DotNetCleanupArtifacts", + "stageLabel": "release", + "enabled": true, + "includePatterns": [ + "*" + ], + "excludePatterns": [ + "*.zip" + ] + } + ] +} diff --git a/utils/engines/test/Invoke-TestEngine.ps1 b/utils/engines/test/Invoke-TestEngine.ps1 new file mode 100644 index 0000000..f4da98e --- /dev/null +++ b/utils/engines/test/Invoke-TestEngine.ps1 @@ -0,0 +1,50 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Plugin-driven test and coverage engine entry script. +#> + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path + +. (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1') +Import-EngineModules -Engine Test + +$settings = Get-ScriptSettings -ScriptDir $scriptDir +$configuredPlugins = Get-ConfiguredPlugins -Settings $settings + +Write-Log -Level 'STEP' -Message '==================================================' +Write-Log -Level 'STEP' -Message 'TEST ENGINE' +Write-Log -Level 'STEP' -Message '==================================================' + +$engineContext = New-EngineContext -ScriptDir $scriptDir -SrcDir $srcDir -Settings $settings + +if ($configuredPlugins.Count -eq 0) { + Write-Log -Level 'WARN' -Message 'No plugins configured in scriptSettings.json.' + exit 0 +} + +$testHadPluginFailures = $false + +foreach ($plugin in $configuredPlugins) { + $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -EngineDirectory $scriptDir -ContinueOnError:$false + if (-not $pluginSucceeded) { + $testHadPluginFailures = $true + break + } +} + +Write-Log -Level 'OK' -Message '==================================================' +if ($testHadPluginFailures) { + Write-Log -Level 'ERROR' -Message 'TEST RUN FAILED' +} +else { + Write-Log -Level 'OK' -Message 'TEST RUN COMPLETE' +} +Write-Log -Level 'OK' -Message '==================================================' + +if ($testHadPluginFailures) { + exit 1 +} diff --git a/utils/engines/test/custom/.gitkeep b/utils/engines/test/custom/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/utils/engines/test/scriptSettings.json b/utils/engines/test/scriptSettings.json new file mode 100644 index 0000000..c06e91d --- /dev/null +++ b/utils/engines/test/scriptSettings.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Run Tests Script Settings", + "description": "Plugin-driven tests and coverage badges for PodmanClient.DotNet.", + "paths": { + "badgesDir": "..\\..\\..\\assets\\badges" + }, + "plugins": [ + { + "name": "DotNetTest", + "stageLabel": "test", + "enabled": true, + "projects": [ + "..\\..\\..\\src\\PodmanClientDotNet.Tests" + ] + }, + { + "name": "QualityGate", + "stageLabel": "qualityGate", + "enabled": true, + "coverageThreshold": 0, + "scanVulnerabilities": false + }, + { + "name": "CoverageBadges", + "stageLabel": "report", + "enabled": true, + "badgesDir": "..\\..\\..\\assets\\badges", + "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" + } + ], + "colorThresholds": { + "brightgreen": 80, + "green": 60, + "yellowgreen": 40, + "yellow": 20, + "orange": 10, + "red": 0 + } + } + ] +} diff --git a/utils/modules/ChangelogSupport.psm1 b/utils/modules/ChangelogSupport.psm1 new file mode 100644 index 0000000..feb7afa --- /dev/null +++ b/utils/modules/ChangelogSupport.psm1 @@ -0,0 +1,56 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Keep a Changelog header parsing and section extraction. + +.DESCRIPTION + Supports only the standard Keep a Changelog version line: + ## [1.0.0] - 2026-05-24 +#> + +function Get-ChangelogVersionHeaderPattern { + return '(?m)^##\s+\[(\d+\.\d+\.\d+)\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$' +} + +function Get-ChangelogNextVersionHeaderPattern { + return '(?m)^##\s+\[\d+\.\d+\.\d+\]\s*-\s*\d{4}-\d{2}-\d{2}\s*$' +} + +function Get-LatestChangelogVersion { + param( + [Parameter(Mandatory = $true)] + [string]$ReleaseNotesContent + ) + + $match = [regex]::Match($ReleaseNotesContent, (Get-ChangelogVersionHeaderPattern)) + if (-not $match.Success) { + return $null + } + + return $match.Groups[1].Value +} + +function Get-ChangelogReleaseNotesSection { + param( + [Parameter(Mandatory = $true)] + [string]$ReleaseNotesContent, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + $escapedVersion = [regex]::Escape($Version) + $nextHeaderPattern = Get-ChangelogNextVersionHeaderPattern + $headerPattern = "(?ms)^##\s+\[$escapedVersion\]\s*-\s*\d{4}-\d{2}-\d{2}.*?(?=$nextHeaderPattern|\Z)" + $match = [regex]::Match($ReleaseNotesContent, $headerPattern) + + if (-not $match.Success) { + return $null + } + + return $match.Value.Trim() +} + +Export-ModuleMember -Function Get-ChangelogVersionHeaderPattern, Get-ChangelogNextVersionHeaderPattern, Get-LatestChangelogVersion, Get-ChangelogReleaseNotesSection diff --git a/utils/modules/Engine/EngineContext.psm1 b/utils/modules/Engine/EngineContext.psm1 new file mode 100644 index 0000000..9d397d1 --- /dev/null +++ b/utils/modules/Engine/EngineContext.psm1 @@ -0,0 +1,225 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Helpers to resolve engine semver and relative paths from plugin configuration. + +.DESCRIPTION + Used by New-EngineContext and version plugins: + - DotNetReleaseVersion plugin -> projectFiles (.csproj ) + - NpmReleaseVersion plugin -> packageJsonPath (package.json version) +#> + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +function Resolve-RelativePaths { + param( + [Parameter(Mandatory = $true)] + [object]$Value, + + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + if ($null -eq $Value) { + return @() + } + + $rawPaths = @() + if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { + $rawPaths += $Value + } + else { + $rawPaths += $Value + } + + $resolved = @() + foreach ($p in $rawPaths) { + if ([string]::IsNullOrWhiteSpace([string]$p)) { + continue + } + + $resolved += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$p))) + } + + return @($resolved) +} + +function Get-CsprojPropertyValue { + param( + [Parameter(Mandatory = $true)] + [xml]$Csproj, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + # SDK-style .csproj files can have multiple PropertyGroup nodes. + # Use the first group that defines the requested property. + $propNode = $Csproj.Project.PropertyGroup | + Where-Object { $_.$PropertyName } | + Select-Object -First 1 + + if ($propNode) { + return $propNode.$PropertyName + } + + return $null +} + +function Get-CsprojVersions { + param( + [Parameter(Mandatory = $true)] + [string[]]$ProjectFiles + ) + + Write-Log -Level "INFO" -Message "Reading version(s) from SDK-style project files (projectFiles)..." + $projectVersions = @{} + + foreach ($projectPath in $ProjectFiles) { + if (-not (Test-Path $projectPath -PathType Leaf)) { + Write-Error "Project file not found at: $projectPath" + exit 1 + } + + if ([System.IO.Path]::GetExtension($projectPath) -ne ".csproj") { + Write-Error "Configured project file is not a .csproj file: $projectPath" + exit 1 + } + + [xml]$csproj = Get-Content $projectPath + $version = Get-CsprojPropertyValue -Csproj $csproj -PropertyName "Version" + + if (-not $version) { + Write-Error "Version not found in $projectPath" + exit 1 + } + + $projectVersions[$projectPath] = $version + Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($projectPath)): $version" + } + + return $projectVersions +} + +function Resolve-DotNetReleaseVersion { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + $releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' } | Select-Object -First 1) + if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) { + Write-Error "Configure a DotNetReleaseVersion plugin in scriptSettings.json with projectFiles." + exit 1 + } + + $releaseVersionSettings = $releaseVersionPlugin[0] + $projectFiles = @(Resolve-RelativePaths -Value $releaseVersionSettings.projectFiles -BasePath $ScriptDir) + + if ($projectFiles.Count -eq 0) { + Write-Error "Configure release version via DotNetReleaseVersion.projectFiles (first .csproj with )." + exit 1 + } + + $projectVersions = Get-CsprojVersions -ProjectFiles $projectFiles + $version = $projectVersions[$projectFiles[0]] + + return [pscustomobject]@{ + version = $version + source = 'DotNetReleaseVersion' + } +} + +function Resolve-NpmReleaseVersion { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + $releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' } | Select-Object -First 1) + if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) { + Write-Error "Configure an NpmReleaseVersion plugin in scriptSettings.json with packageJsonPath." + exit 1 + } + + $releaseVersionSettings = $releaseVersionPlugin[0] + $packageJsonPaths = @(Resolve-RelativePaths -Value $releaseVersionSettings.packageJsonPath -BasePath $ScriptDir) + + if ($packageJsonPaths.Count -eq 0) { + Write-Error "Configure release version via NpmReleaseVersion.packageJsonPath." + exit 1 + } + + $packageJsonPath = $packageJsonPaths[0] + if (-not (Test-Path $packageJsonPath -PathType Leaf)) { + Write-Error "NpmReleaseVersion: package.json not found at: $packageJsonPath" + exit 1 + } + + Write-Log -Level "INFO" -Message "Reading version from npm package.json (packageJsonPath)..." + $json = Get-Content -Path $packageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json + $version = [string]$json.version + if ([string]::IsNullOrWhiteSpace($version)) { + Write-Error "NpmReleaseVersion: 'version' is missing in '$packageJsonPath'." + exit 1 + } + + if ($version -notmatch '^\d+\.\d+\.\d+') { + Write-Error "NpmReleaseVersion: version '$version' in '$packageJsonPath' is not a valid semver." + exit 1 + } + + Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($packageJsonPath)): $version" + + return [pscustomobject]@{ + version = $version + source = 'NpmReleaseVersion' + } +} + +function Resolve-ReleaseVersion { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + $dotnetPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' -and $_.enabled -ne $false }) + $npmPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' -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." + exit 1 + } + + if ($dotnetPlugin.Count -gt 0) { + return Resolve-DotNetReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir + } + + if ($npmPlugin.Count -gt 0) { + return Resolve-NpmReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir + } + + Write-Error "Configure a DotNetReleaseVersion plugin (projectFiles) or NpmReleaseVersion plugin (packageJsonPath) in scriptSettings.json." + exit 1 +} + +Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion, Resolve-NpmReleaseVersion, Resolve-ReleaseVersion + + + diff --git a/utils/modules/Engine/Import-EngineModules.ps1 b/utils/modules/Engine/Import-EngineModules.ps1 new file mode 100644 index 0000000..5c43ed7 --- /dev/null +++ b/utils/modules/Engine/Import-EngineModules.ps1 @@ -0,0 +1,35 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Import-EngineModules { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Release', 'Test')] + [string]$Engine + ) + + $engineModuleDir = $PSScriptRoot + $modulesDir = Split-Path $engineModuleDir -Parent + $supportModules = @( + (Join-Path $modulesDir 'ScriptConfig.psm1'), + (Join-Path $modulesDir 'Logging.psm1'), + (Join-Path $engineModuleDir 'PluginSupport.psm1'), + (Join-Path $engineModuleDir 'EngineContext.psm1') + ) + + if ($Engine -eq 'Release') { + $supportModules += (Join-Path $engineModuleDir 'ReleaseSupport.psm1') + } + else { + $supportModules += (Join-Path $engineModuleDir 'TestSupport.psm1') + } + + foreach ($modulePath in $supportModules) { + if (-not (Test-Path $modulePath -PathType Leaf)) { + throw "Required module not found at: $modulePath" + } + + Import-Module $modulePath -Force + } +} diff --git a/utils/modules/Engine/PluginSupport.psm1 b/utils/modules/Engine/PluginSupport.psm1 new file mode 100644 index 0000000..30371ea --- /dev/null +++ b/utils/modules/Engine/PluginSupport.psm1 @@ -0,0 +1,386 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Get-RepoUtilsSrcDirectory { + return (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) +} + +function Get-RepoUtilsModulesDirectory { + return Split-Path $PSScriptRoot -Parent +} + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path (Get-RepoUtilsModulesDirectory) "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +function Import-PluginDependency { + param( + [Parameter(Mandatory = $true)] + [string]$ModuleName, + + [Parameter(Mandatory = $true)] + [string]$RequiredCommand + ) + + if (Get-Command $RequiredCommand -ErrorAction SilentlyContinue) { + return + } + + $modulesDir = Get-RepoUtilsModulesDirectory + $engineModuleDir = $PSScriptRoot + $modulePath = Join-Path $modulesDir "$ModuleName.psm1" + if (-not (Test-Path $modulePath -PathType Leaf)) { + $modulePath = Join-Path $engineModuleDir "$ModuleName.psm1" + } + + if (Test-Path $modulePath -PathType Leaf) { + Import-Module $modulePath -Force -Global -ErrorAction Stop + } + + if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) { + throw "Required command '$RequiredCommand' is still unavailable after importing module '$ModuleName'." + } +} + +function Get-ConfiguredPlugins { + param( + [Parameter(Mandatory = $true)] + [psobject]$Settings + ) + + if (-not $Settings.PSObject.Properties['plugins'] -or $null -eq $Settings.plugins) { + return @() + } + + if ($Settings.plugins -is [System.Collections.IEnumerable] -and -not ($Settings.plugins -is [string])) { + return @($Settings.plugins) + } + + return @($Settings.plugins) +} + +function Get-PluginStageLabel { + param( + [Parameter(Mandatory = $true)] + $Plugin + ) + + if (-not $Plugin.PSObject.Properties['stageLabel'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.stageLabel)) { + return 'release' + } + + return [string]$Plugin.stageLabel +} + +function Get-PluginBranches { + param( + [Parameter(Mandatory = $true)] + $Plugin + ) + + if (-not $Plugin.PSObject.Properties['branches'] -or $null -eq $Plugin.branches) { + return @() + } + + if ($Plugin.branches -is [System.Collections.IEnumerable] -and -not ($Plugin.branches -is [string])) { + return @($Plugin.branches | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + + if ([string]::IsNullOrWhiteSpace([string]$Plugin.branches)) { + return @() + } + + return @([string]$Plugin.branches) +} + +function Test-PluginAllowedOnBranch { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$CurrentBranch + ) + + $allowedBranches = Get-PluginBranches -Plugin $Plugin + if ($allowedBranches.Count -eq 0) { + return $true + } + + if ($allowedBranches -contains '*') { + return $true + } + + return $allowedBranches -contains $CurrentBranch +} + +function Test-IsPublishPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin + ) + + if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) { + return $false + } + + return @('GitHub', 'DotNetNuGet', 'DotNetDockerPush', 'DotNetHelmPush', 'NpmPublish') -contains ([string]$Plugin.name) +} + +function Get-PluginSettingValue { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + foreach ($plugin in $Plugins) { + if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) { + continue + } + + if (-not $plugin.PSObject.Properties[$PropertyName]) { + continue + } + + $value = $plugin.$PropertyName + if ($null -eq $value) { + continue + } + + if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) { + continue + } + + return $value + } + + return $null +} + +function Get-PluginPathListSetting { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + $rawPaths = @() + $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName + + if ($null -eq $value) { + return @() + } + + if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) { + $rawPaths += $value + } + else { + $rawPaths += $value + } + + $resolvedPaths = @() + foreach ($path in $rawPaths) { + if ([string]::IsNullOrWhiteSpace([string]$path)) { + continue + } + + $resolvedPaths += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$path))) + } + + return @($resolvedPaths) +} + +function Get-PluginPathSetting { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $true)] + [string]$BasePath + ) + + $value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName + if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) { + return $null + } + + return [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$value))) +} + +function Get-ArchiveNamePattern { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$CurrentBranch + ) + + foreach ($plugin in $Plugins) { + if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) { + continue + } + + if (-not $plugin.enabled) { + continue + } + + if (-not (Test-PluginAllowedOnBranch -Plugin $plugin -CurrentBranch $CurrentBranch)) { + continue + } + + if ($plugin.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$plugin.zipNamePattern)) { + return [string]$plugin.zipNamePattern + } + } + + return "release-{version}.zip" +} + +function Resolve-PluginModulePath { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory + ) + + $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) + ) + + foreach ($candidatePath in $candidatePaths) { + if (Test-Path $candidatePath -PathType Leaf) { + return $candidatePath + } + } + + return $candidatePaths[0] +} + +function Test-PluginRunnable { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory, + + [Parameter(Mandatory = $false)] + [bool]$WriteLogs = $true + ) + + if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.name)) { + if ($WriteLogs) { + Write-Log -Level "WARN" -Message "Skipping plugin entry with no name." + } + return $false + } + + if (-not $Plugin.enabled) { + if ($WriteLogs) { + Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.name)' (disabled)." + } + return $false + } + + $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $pluginModulePath -PathType Leaf)) { + if ($WriteLogs) { + Write-Log -Level "ERROR" -Message "Plugin module not found: $pluginModulePath" + } + return $false + } + + return $true +} + +function New-PluginInvocationSettings { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + $properties = @{} + foreach ($property in $Plugin.PSObject.Properties) { + $properties[$property.Name] = $property.Value + } + + $properties['context'] = $SharedSettings + return [pscustomobject]$properties +} + +function Invoke-ConfiguredPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory, + + [Parameter(Mandatory = $false)] + [bool]$ContinueOnError = $false + ) + + if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) { + if ($Plugin.enabled) { + return $false + } + + return $true + } + + 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 + } + + $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..." + + try { + $moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop + $invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop + $pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings + + & $invokeCommand -Settings $pluginSettings + Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed." + return $true + } + catch { + Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.name)' failed: $($_.Exception.Message)" + return $false + } +} + +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 diff --git a/utils/modules/Engine/ReleaseSupport.psm1 b/utils/modules/Engine/ReleaseSupport.psm1 new file mode 100644 index 0000000..ffda8d7 --- /dev/null +++ b/utils/modules/Engine/ReleaseSupport.psm1 @@ -0,0 +1,151 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +$modulesDir = Split-Path $PSScriptRoot -Parent + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path $modulesDir "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +if (-not (Get-Command Get-CurrentBranch -ErrorAction SilentlyContinue)) { + $gitToolsModulePath = Join-Path $modulesDir "GitTools.psm1" + if (Test-Path $gitToolsModulePath -PathType Leaf) { + Import-Module $gitToolsModulePath -Force + } +} + +if (-not (Get-Command Get-PluginStageLabel -ErrorAction SilentlyContinue) -or -not (Get-Command Test-IsPublishPlugin -ErrorAction SilentlyContinue)) { + $pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force + } +} + +if (-not (Get-Command Resolve-ReleaseVersion -ErrorAction SilentlyContinue)) { + $engineContextModulePath = Join-Path $PSScriptRoot "EngineContext.psm1" + if (Test-Path $engineContextModulePath -PathType Leaf) { + Import-Module $engineContextModulePath -Force + } +} + +function Assert-WorkingTreeClean { + $gitStatus = Get-GitStatusShort + if (-not [string]::IsNullOrWhiteSpace([string]$gitStatus)) { + Write-Log -Level "WARN" -Message " Uncommitted changes detected (use ReleasePublishGuard requireCleanWorkingTree to block publish)." + foreach ($line in @([string]$gitStatus -split "`r?`n")) { + if (-not [string]::IsNullOrWhiteSpace($line)) { + Write-Log -Level "WARN" -Message " $line" + } + } + return + } + + Write-Log -Level "OK" -Message " Working directory is clean." +} + +function Initialize-ReleaseStageContext { + param( + [Parameter(Mandatory = $true)] + [object[]]$RemainingPlugins, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$ArtifactsDirectory, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + if (-not $SharedSettings.PSObject.Properties['releaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.releaseDir)) { + $SharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $ArtifactsDirectory -Force + } +} + +function New-EngineContext { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [Parameter(Mandatory = $true)] + [string]$SrcDir, + + [Parameter(Mandatory = $false)] + [psobject]$Settings + ) + + $resolvedVersion = Resolve-ReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir + $version = $resolvedVersion.version + $versionSource = $resolvedVersion.source + $releaseRelative = '..\..\..\release' + $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $releaseRelative)) + + $currentBranch = Get-CurrentBranch + + $releaseBranches = @() + foreach ($p in $Plugins) { + if (-not $p.enabled) { continue } + if ([string]$p.name -ne 'ReleasePublishGuard') { continue } + foreach ($b in (Get-PluginBranches -Plugin $p)) { + $releaseBranches += $b + } + } + $releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) + if ($releaseBranches.Count -eq 0) { + foreach ($p in ($Plugins | Where-Object { Test-IsPublishPlugin -Plugin $_ })) { + if (-not $p.enabled) { continue } + foreach ($b in (Get-PluginBranches -Plugin $p)) { + $releaseBranches += $b + } + } + $releaseBranches = @($releaseBranches | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) + } + if ($releaseBranches.Count -eq 0) { + $releaseBranches = @('main') + } + + $isReleaseBranch = $releaseBranches -contains $currentBranch + $isNonReleaseBranch = -not $isReleaseBranch + + Assert-WorkingTreeClean + + $tag = "v$version" + Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)." + + return [pscustomobject]@{ + scriptDir = $ScriptDir + srcDir = $SrcDir + utilsDir = $SrcDir + currentBranch = $currentBranch + version = $version + tag = $tag + artifactsDirectory = $artifactsDirectory + isReleaseBranch = $isReleaseBranch + isNonReleaseBranch = $isNonReleaseBranch + releaseBranches = $releaseBranches + publishCompleted = $false + skipPublishPlugins = $false + } +} + +function Get-PreferredReleaseBranch { + param( + [Parameter(Mandatory = $true)] + [psobject]$EngineContext + ) + + if ($EngineContext.releaseBranches.Count -gt 0) { + return $EngineContext.releaseBranches[0] + } + + return "main" +} + +Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch diff --git a/utils/modules/Engine/TestSupport.psm1 b/utils/modules/Engine/TestSupport.psm1 new file mode 100644 index 0000000..a90d03d --- /dev/null +++ b/utils/modules/Engine/TestSupport.psm1 @@ -0,0 +1,38 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +$modulesDir = Split-Path $PSScriptRoot -Parent + +if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { + $loggingModulePath = Join-Path $modulesDir "Logging.psm1" + if (Test-Path $loggingModulePath -PathType Leaf) { + Import-Module $loggingModulePath -Force + } +} + +function New-EngineContext { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [Parameter(Mandatory = $true)] + [string]$SrcDir, + + [Parameter(Mandatory = $false)] + [psobject]$Settings + ) + + $badgesDir = $null + if ($Settings -and $Settings.PSObject.Properties['paths'] -and $Settings.paths.badgesDir) { + $badgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir ([string]$Settings.paths.badgesDir))) + } + + return [pscustomobject]@{ + scriptDir = $ScriptDir + srcDir = $SrcDir + utilsDir = $SrcDir + badgesDir = $badgesDir + } +} + +Export-ModuleMember -Function New-EngineContext diff --git a/utils/modules/GitTools.psm1 b/utils/modules/GitTools.psm1 new file mode 100644 index 0000000..be56c94 --- /dev/null +++ b/utils/modules/GitTools.psm1 @@ -0,0 +1,268 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +# +# Shared Git helpers for utility scripts. +# + +function Import-LoggingModuleInternal { + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + return + } + + $modulePath = Join-Path $PSScriptRoot "Logging.psm1" + if (Test-Path $modulePath) { + Import-Module $modulePath -Force + } +} + +function Write-GitToolsLogInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")] + [string]$Level = "INFO" + ) + + Import-LoggingModuleInternal + + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + Write-Log -Level $Level -Message $Message + return + } + + Write-Host $Message -ForegroundColor Gray +} + +# Internal: +# Purpose: +# - Execute a git command and enforce fail-fast error handling. +function Invoke-GitInternal { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [Parameter(Mandatory = $false)] + [switch]$CaptureOutput, + + [Parameter(Mandatory = $false)] + [string]$ErrorMessage = "Git command failed" + ) + + if ($CaptureOutput) { + $output = & git @Arguments 2>&1 + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + Write-Error "$ErrorMessage (exit code: $exitCode)" + exit 1 + } + + if ($null -eq $output) { + return "" + } + + return ($output -join "`n").Trim() + } + + & git @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + Write-Error "$ErrorMessage (exit code: $exitCode)" + exit 1 + } +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Resolve and print the current branch name. +function Get-CurrentBranch { + Write-GitToolsLogInternal -Level "STEP" -Message "Detecting current branch..." + + $branch = Invoke-GitInternal -Arguments @("rev-parse", "--abbrev-ref", "HEAD") -CaptureOutput -ErrorMessage "Could not determine current branch" + Write-GitToolsLogInternal -Level "OK" -Message "Branch: $branch" + return $branch +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Return `git status --short` output for pending-change checks. +function Get-GitStatusShort { + return Invoke-GitInternal -Arguments @("status", "--short") -CaptureOutput -ErrorMessage "Failed to get git status" +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# Purpose: +# - Get exact tag name attached to HEAD (release flow). +function Get-CurrentCommitTag { + param( + [Parameter(Mandatory = $true)] + [string]$Version + ) + + Write-GitToolsLogInternal -Level "STEP" -Message "Checking for tag on current commit..." + $tag = Invoke-GitInternal -Arguments @("describe", "--tags", "--exact-match", "HEAD") -CaptureOutput -ErrorMessage "No tag found on current commit. Create a tag: git tag v$Version" + return $tag +} + +# Used by: +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get all tag names pointing at HEAD. +function Get-HeadTags { + $tagsRaw = Invoke-GitInternal -Arguments @("tag", "--points-at", "HEAD") -CaptureOutput -ErrorMessage "Failed to list tags on HEAD" + + if ([string]::IsNullOrWhiteSpace($tagsRaw)) { + return @() + } + + return @($tagsRaw -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# Purpose: +# - Check whether a given tag exists on the remote. +function Test-RemoteTagExists { + param( + [Parameter(Mandatory = $true)] + [string]$Tag, + + [Parameter(Mandatory = $false)] + [string]$Remote = "origin" + ) + + $remoteTag = Invoke-GitInternal -Arguments @("ls-remote", "--tags", $Remote, $Tag) -CaptureOutput -ErrorMessage "Failed to check remote tag existence" + return [bool]$remoteTag +} + +# Used by: +# - utils/Release-NuGetPackage/Release-NuGetPackage.ps1 +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Push tag to remote (optionally with `--force`). +function Push-TagToRemote { + param( + [Parameter(Mandatory = $true)] + [string]$Tag, + + [Parameter(Mandatory = $false)] + [string]$Remote = "origin", + + [Parameter(Mandatory = $false)] + [switch]$Force + ) + + $pushArgs = @("push") + if ($Force) { + $pushArgs += "--force" + } + $pushArgs += @($Remote, $Tag) + + Invoke-GitInternal -Arguments $pushArgs -ErrorMessage "Failed to push tag $Tag to remote $Remote" +} + +# Used by: +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Push branch to remote (optionally with `--force`). +function Push-BranchToRemote { + param( + [Parameter(Mandatory = $true)] + [string]$Branch, + + [Parameter(Mandatory = $false)] + [string]$Remote = "origin", + + [Parameter(Mandatory = $false)] + [switch]$Force + ) + + $pushArgs = @("push") + if ($Force) { + $pushArgs += "--force" + } + $pushArgs += @($Remote, $Branch) + + Invoke-GitInternal -Arguments $pushArgs -ErrorMessage "Failed to push branch $Branch to remote $Remote" +} + +# Used by: +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get HEAD commit hash. +function Get-HeadCommitHash { + param( + [Parameter(Mandatory = $false)] + [switch]$Short + ) + + $format = if ($Short) { "--format=%h" } else { "--format=%H" } + return Invoke-GitInternal -Arguments @("log", "-1", $format) -CaptureOutput -ErrorMessage "Failed to get HEAD commit hash" +} + +# Used by: +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get HEAD commit subject line. +function Get-HeadCommitMessage { + return Invoke-GitInternal -Arguments @("log", "-1", "--format=%s") -CaptureOutput -ErrorMessage "Failed to get HEAD commit message" +} + +# Used by: +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Stage all changes (tracked, untracked, deletions). +function Add-AllChanges { + Invoke-GitInternal -Arguments @("add", "-A") -ErrorMessage "Failed to stage changes" +} + +# Used by: +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Amend HEAD commit and keep existing commit message. +function Update-HeadCommitNoEdit { + Invoke-GitInternal -Arguments @("commit", "--amend", "--no-edit") -ErrorMessage "Failed to amend commit" +} + +# Used by: +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Delete local tag. +function Remove-LocalTag { + param( + [Parameter(Mandatory = $true)] + [string]$Tag + ) + + Invoke-GitInternal -Arguments @("tag", "-d", $Tag) -ErrorMessage "Failed to delete local tag" +} + +# Used by: +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Create local tag. +function New-LocalTag { + param( + [Parameter(Mandatory = $true)] + [string]$Tag + ) + + Invoke-GitInternal -Arguments @("tag", $Tag) -ErrorMessage "Failed to create tag" +} + +# Used by: +# - tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 +# Purpose: +# - Get HEAD one-line commit info. +function Get-HeadCommitOneLine { + return Invoke-GitInternal -Arguments @("log", "-1", "--oneline") -CaptureOutput -ErrorMessage "Failed to read final commit state" +} + +Export-ModuleMember -Function Get-CurrentBranch, Get-GitStatusShort, Get-CurrentCommitTag, Get-HeadTags, Test-RemoteTagExists, Push-TagToRemote, Push-BranchToRemote, Get-HeadCommitHash, Get-HeadCommitMessage, Add-AllChanges, Update-HeadCommitNoEdit, Remove-LocalTag, New-LocalTag, Get-HeadCommitOneLine diff --git a/utils/modules/Logging.psm1 b/utils/modules/Logging.psm1 new file mode 100644 index 0000000..a0cbb3d --- /dev/null +++ b/utils/modules/Logging.psm1 @@ -0,0 +1,70 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Get-LogTimestampInternal { + return (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") +} + +function Get-LogColorInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Level + ) + + switch ($Level.ToUpperInvariant()) { + "OK" { return "Green" } + "INFO" { return "Gray" } + "WARN" { return "Yellow" } + "ERROR" { return "Red" } + "STEP" { return "Cyan" } + "DEBUG" { return "DarkGray" } + default { return "White" } + } +} + +function Write-Log { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")] + [string]$Level = "INFO", + + [Parameter(Mandatory = $false)] + [switch]$NoTimestamp + ) + + $levelToken = "[$($Level.ToUpperInvariant())]" + $padding = " " * [Math]::Max(1, (10 - $levelToken.Length)) + $prefix = if ($NoTimestamp) { "" } else { "[$(Get-LogTimestampInternal)] " } + $line = "$prefix$levelToken$padding$Message" + + Write-Host $line -ForegroundColor (Get-LogColorInternal -Level $Level) +} + +function Write-LogStep { + param( + [Parameter(Mandatory = $true)] + [string]$Message + ) + + Write-Log -Level "STEP" -Message $Message +} + +function Write-LogStepResult { + param( + [Parameter(Mandatory = $true)] + [ValidateSet("OK", "FAIL")] + [string]$Status, + + [Parameter(Mandatory = $false)] + [string]$Message + ) + + $level = if ($Status -eq "FAIL") { "ERROR" } else { "OK" } + $text = if ([string]::IsNullOrWhiteSpace($Message)) { $Status } else { $Message } + Write-Log -Level $level -Message $text +} + +Export-ModuleMember -Function Write-Log, Write-LogStep, Write-LogStepResult diff --git a/utils/modules/ScriptConfig.psm1 b/utils/modules/ScriptConfig.psm1 new file mode 100644 index 0000000..26bd953 --- /dev/null +++ b/utils/modules/ScriptConfig.psm1 @@ -0,0 +1,35 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Get-ScriptSettings { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [Parameter(Mandatory = $false)] + [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 + } + + return Get-Content $settingsPath -Raw | ConvertFrom-Json +} + +function Assert-Command { + param( + [Parameter(Mandatory = $true)] + [string]$Command + ) + + if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { + Write-Error "Required command '$Command' is missing. Aborting." + exit 1 + } +} + +Export-ModuleMember -Function Get-ScriptSettings, Assert-Command diff --git a/utils/modules/TestRunner.psm1 b/utils/modules/TestRunner.psm1 new file mode 100644 index 0000000..de1a493 --- /dev/null +++ b/utils/modules/TestRunner.psm1 @@ -0,0 +1,431 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + PowerShell module for running tests with code coverage. + +.DESCRIPTION + Provides the Invoke-TestsWithCoverage function for running .NET tests + with Coverlet code coverage collection and parsing results. + +.NOTES + Author: MaksIT + Usage: pwsh -Command "Import-Module .\TestRunner.psm1" +#> + +function Import-LoggingModuleInternal { + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + return + } + + $modulePath = Join-Path $PSScriptRoot "Logging.psm1" + if (Test-Path $modulePath) { + Import-Module $modulePath -Force + } +} + +function Write-TestRunnerLogInternal { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [ValidateSet("INFO", "OK", "WARN", "ERROR", "STEP", "DEBUG")] + [string]$Level = "INFO" + ) + + Import-LoggingModuleInternal + + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + Write-Log -Level $Level -Message $Message + return + } + + Write-Host $Message -ForegroundColor Gray +} + +function Invoke-TestsWithCoverage { + <# + .SYNOPSIS + Runs unit tests with code coverage and returns coverage metrics. + + .PARAMETER TestProjectPath + One or more paths to test project directories (or .csproj files). Each project + is tested in order; coverage metrics are aggregated across all Cobertura outputs. + + .PARAMETER Silent + Suppress console output (for JSON consumption). + + .PARAMETER ResultsDirectory + Optional fixed directory where test result files are written. + + .PARAMETER KeepResults + Keep the TestResults folder after execution. + + .OUTPUTS + PSCustomObject with properties: + - Success: bool + - Error: string (if failed) + - LineRate: double + - BranchRate: double + - MethodRate: double + - TotalMethods: int + - CoveredMethods: int + - CoverageFile: string (ReportGenerator reports arg: one file, or semicolon-separated) + - CoverageFiles: string[] (individual Cobertura paths) + - ResultsDirectory: string (absolute folder used for dotnet test output; may be removed after run unless -KeepResults) + + .EXAMPLE + $result = Invoke-TestsWithCoverage -TestProjectPath ".\Tests" + if ($result.Success) { Write-TestRunnerLogInternal -Level "INFO" -Message "Line coverage: $($result.LineRate)%" } + + .EXAMPLE + $result = Invoke-TestsWithCoverage -TestProjectPath @(".\ProjA.Tests", ".\ProjB.Tests") + #> + param( + [Parameter(Mandatory = $true)] + [string[]]$TestProjectPath, + + [switch]$Silent, + + [string]$ResultsDirectory, + + [switch]$KeepResults + ) + + $ErrorActionPreference = "Stop" + + # Normalize to a non-empty list of absolute .csproj paths. + $resolvedProjectFiles = [System.Collections.Generic.List[string]]::new() + foreach ($raw in $TestProjectPath) { + if ([string]::IsNullOrWhiteSpace($raw)) { continue } + $full = [System.IO.Path]::GetFullPath($raw.Trim()) + if (-not (Test-Path $full)) { + return [PSCustomObject]@{ + Success = $false + 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) + } + } + + if ($resolvedProjectFiles.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" + } + else { + $ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory) + } + + # Clean previous results once (shared output for all test runs). + if (Test-Path $ResultsDir) { + Remove-Item -Recurse -Force $ResultsDir + } + New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null + + 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" + } + } + + $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 + } + + if ($LASTEXITCODE -ne 0) { + return [PSCustomObject]@{ + Success = $false + Error = "Build failed for '$projectFile' with exit code $LASTEXITCODE" + } + } + } + + 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" + } + } + } + + $coverageFiles = @(Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Sort-Object FullName) + + if ($coverageFiles.Count -eq 0) { + return [PSCustomObject]@{ + Success = $false + Error = "Coverage file not found under: $ResultsDir" + } + } + + if (-not $Silent) { + foreach ($cf in $coverageFiles) { + Write-TestRunnerLogInternal -Level "OK" -Message "Coverage file found: $($cf.FullName)" + } + Write-TestRunnerLogInternal -Level "STEP" -Message "Parsing coverage data..." + } + + # Aggregate line/branch from Cobertura counters; methods by walking all files. + $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 { + # Fallback: average of per-file line-rate when counters are missing (older Cobertura). + $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 }) + $coverageFileReportArg = $coveragePaths -join ";" + $resultsDirectoryFull = [System.IO.Path]::GetFullPath($ResultsDir) + + # Cleanup unless KeepResults is specified + if (-not $KeepResults) { + if (Test-Path $ResultsDir) { + Remove-Item -Recurse -Force $ResultsDir + } + } + + # Return results + return [PSCustomObject]@{ + Success = $true + LineRate = $lineRate + BranchRate = $branchRate + MethodRate = $methodRate + TotalMethods = $totalMethods + CoveredMethods = $coveredMethods + CoverageFile = $coverageFileReportArg + CoverageFiles = $coveragePaths + ResultsDirectory = $resultsDirectoryFull + } +} + +function Invoke-NpmJestTestsWithCoverage { + <# + .SYNOPSIS + Runs npm/Jest tests with coverage and returns normalized metrics. + + .PARAMETER WorkspaceRoot + npm workspace root (folder containing package.json and jest.config). + + .PARAMETER TestScript + npm script name to run (default: test). Coverage flags are appended via `--`. + + .PARAMETER CoverageDirectory + Relative path under WorkspaceRoot where Jest writes coverage output. + + .PARAMETER Silent + Suppress console output from npm. + + .OUTPUTS + Same metric shape as Invoke-TestsWithCoverage, plus CoverageSummaryFile when available. + #> + param( + [Parameter(Mandatory = $true)] + [string]$WorkspaceRoot, + + [string]$TestScript = 'test', + + [string]$CoverageDirectory = 'coverage', + + [switch]$Silent + ) + + $ErrorActionPreference = 'Stop' + $workspaceFull = [System.IO.Path]::GetFullPath($WorkspaceRoot) + if (-not (Test-Path (Join-Path $workspaceFull 'package.json') -PathType Leaf)) { + return [PSCustomObject]@{ + Success = $false + Error = "package.json not found in workspace root: $workspaceFull" + } + } + + if (-not $Silent) { + Write-TestRunnerLogInternal -Level 'STEP' -Message 'Running npm/Jest tests with coverage...' + Write-TestRunnerLogInternal -Level 'INFO' -Message "Workspace: $workspaceFull" + } + + Push-Location $workspaceFull + try { + $npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text') + if ($Silent) { + $null = & npm @npmArgs 2>&1 + } + else { + & npm @npmArgs + } + + if ($LASTEXITCODE -ne 0) { + return [PSCustomObject]@{ + Success = $false + Error = "npm run $TestScript failed with exit code $LASTEXITCODE" + } + } + } + finally { + Pop-Location + } + + $summaryPath = Join-Path $workspaceFull (Join-Path $CoverageDirectory 'coverage-summary.json') + if (-not (Test-Path $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" + } + } + + $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 + $resultsDirectory = [System.IO.Path]::GetFullPath((Join-Path $workspaceFull $CoverageDirectory)) + + if (-not $Silent) { + Write-TestRunnerLogInternal -Level 'OK' -Message "Coverage summary: $summaryPath" + } + + return [PSCustomObject]@{ + Success = $true + LineRate = $lineRate + BranchRate = $branchRate + MethodRate = $methodRate + TotalMethods = $totalMethods + CoveredMethods = $coveredMethods + CoverageSummaryFile = $summaryPath + ResultsDirectory = $resultsDirectory + } +} + +Export-ModuleMember -Function Invoke-TestsWithCoverage, Invoke-NpmJestTestsWithCoverage diff --git a/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 b/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 new file mode 100644 index 0000000..bbc7459 --- /dev/null +++ b/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 @@ -0,0 +1,122 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .NET artifact cleanup plugin — remove NuGet build outputs after release. + +.DESCRIPTION + Removes files from the configured artifacts directory using glob patterns. + Defaults target NuGet outputs (*.nupkg, *.snupkg). Typically placed at the + end of the Release stage after DotNetCreateArchive 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 "DotNetCleanupArtifacts 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/DotNet/DotNetCreateArchive.psm1 b/utils/plugins/DotNet/DotNetCreateArchive.psm1 new file mode 100644 index 0000000..92b34bd --- /dev/null +++ b/utils/plugins/DotNet/DotNetCreateArchive.psm1 @@ -0,0 +1,94 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .NET release archive plugin — zip from NuGet pack/publish artifacts. + +.DESCRIPTION + This plugin compresses .NET release artifact inputs prepared by an earlier + DotNet plugin (DotNetPack or DotNetPublish) into a zip file + and exposes the resulting release assets for later publisher 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" + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $artifactsDirectory = $sharedSettings.artifactsDirectory + $version = $sharedSettings.version + $archiveInputs = @() + + if ($sharedSettings.PSObject.Properties['releaseArchiveInputs'] -and $sharedSettings.releaseArchiveInputs) { + $archiveInputs = @($sharedSettings.releaseArchiveInputs) + } + elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) { + $archiveInputs = @($sharedSettings.packageFile.FullName) + if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) { + $archiveInputs += $sharedSettings.symbolsPackageFile.FullName + } + } + + if ($archiveInputs.Count -eq 0) { + throw "DotNetCreateArchive plugin requires prepared artifacts. Run DotNetPack or DotNetPublish first." + } + + if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { + throw "DotNetCreateArchive plugin requires an artifacts directory in the shared context." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null + } + + $zipNamePattern = if ($pluginSettings.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.zipNamePattern)) { + [string]$pluginSettings.zipNamePattern + } + else { + "release-{version}.zip" + } + + $zipFileName = $zipNamePattern -replace '\{version\}', $version + $zipPath = Join-Path $artifactsDirectory $zipFileName + + if (Test-Path $zipPath) { + Remove-Item -Path $zipPath -Force + } + + Write-Log -Level "STEP" -Message "Creating release archive..." + Compress-Archive -Path $archiveInputs -DestinationPath $zipPath -CompressionLevel Optimal -Force + + if (-not (Test-Path $zipPath -PathType Leaf)) { + throw "Failed to create release archive at: $zipPath" + } + + Write-Log -Level "OK" -Message " Release archive ready: $zipPath" + + $releaseAssetPaths = @($zipPath) + if ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) { + $releaseAssetPaths += $sharedSettings.packageFile.FullName + } + if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) { + $releaseAssetPaths += $sharedSettings.symbolsPackageFile.FullName + } + + $sharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force + $sharedSettings | Add-Member -NotePropertyName releaseArchivePath -NotePropertyValue $zipPath -Force + $sharedSettings | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $releaseAssetPaths -Force +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetDockerPush.psm1 b/utils/plugins/DotNet/DotNetDockerPush.psm1 new file mode 100644 index 0000000..6e6855a --- /dev/null +++ b/utils/plugins/DotNet/DotNetDockerPush.psm1 @@ -0,0 +1,245 @@ +#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 new file mode 100644 index 0000000..44532f0 --- /dev/null +++ b/utils/plugins/DotNet/DotNetHelmPush.psm1 @@ -0,0 +1,181 @@ +#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 new file mode 100644 index 0000000..ecb2eb0 --- /dev/null +++ b/utils/plugins/DotNet/DotNetNuGet.psm1 @@ -0,0 +1,71 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .NET NuGet publish plugin. + +.DESCRIPTION + This plugin publishes the package artifact from shared runtime + context to the configured NuGet feed using the configured API key. +#> + +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" + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $nugetApiKeyEnvVar = $pluginSettings.nugetApiKey + $packageFile = $sharedSettings.packageFile + + 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." + } + + $nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar) + if ([string]::IsNullOrWhiteSpace($nugetApiKey)) { + throw "NuGet API key is not set. Set '$nugetApiKeyEnvVar' and rerun." + } + + $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { + "https://api.nuget.org/v3/index.json" + } + else { + $pluginSettings.source + } + + Write-Log -Level "STEP" -Message "Pushing package to NuGet feed..." + dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate + + if ($LASTEXITCODE -ne 0) { + throw "Failed to push the package to NuGet feed." + } + + Write-Log -Level "OK" -Message " NuGet push completed." + $sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force +} + +Export-ModuleMember -Function Invoke-Plugin + + + diff --git a/utils/plugins/DotNet/DotNetPack.psm1 b/utils/plugins/DotNet/DotNetPack.psm1 new file mode 100644 index 0000000..bffc191 --- /dev/null +++ b/utils/plugins/DotNet/DotNetPack.psm1 @@ -0,0 +1,128 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .NET pack plugin for producing package artifacts. + +.DESCRIPTION + This plugin creates package output for the release pipeline. + It packs the configured .NET project, resolves the generated + package artifacts, and publishes them into shared runtime context + for later 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) { + # Load this globally only as a fallback. Re-importing PluginSupport in its own execution path + # can invalidate commands already resolved by the release engine. + 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" + + $sharedSettings = $Settings.context + $scriptDir = $sharedSettings.scriptDir + $version = $sharedSettings.version + + if ($Settings.PSObject.Properties['projectFiles'] -and $null -ne $Settings.projectFiles) { + $projectFiles = @(Resolve-RelativePaths -Value $Settings.projectFiles -BasePath $scriptDir) + } + elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) { + $projectFiles = @($sharedSettings.projectFiles) + } + else { + $projectFiles = @() + } + + if ($Settings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$Settings.artifactsDir)) { + $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$Settings.artifactsDir))) + } + else { + $artifactsDirectory = $sharedSettings.artifactsDirectory + } + $packageProjectPath = $null + $releaseArchiveInputs = @() + + Assert-Command dotnet + + if ($projectFiles.Count -eq 0) { + throw "DotNetPack plugin requires projectFiles in plugin settings or projectFiles on shared context." + } + + $outputDir = $artifactsDirectory + + if (!(Test-Path $outputDir)) { + New-Item -ItemType Directory -Path $outputDir | Out-Null + } + + # First path in the configured project list is the pack target. + $packageProjectPath = (@($projectFiles))[0] + Write-Log -Level "STEP" -Message "Packing NuGet package..." + $dotnetPackArguments = @( + 'pack', $packageProjectPath, '-c', 'Release', '-o', $outputDir, '--nologo', + '-p:IncludeSymbols=true', '-p:SymbolPackageFormat=snupkg' + ) + & dotnet @dotnetPackArguments + if ($LASTEXITCODE -ne 0) { + throw "dotnet pack failed for $packageProjectPath." + } + + # dotnet pack can leave older packages in the artifacts directory. + # Pick the newest file matching the current version rather than assuming a clean folder. + $packageFile = $null + $newestNupkgWrite = [datetime]::MinValue + $nupkgCandidates = Get-ChildItem -Path $outputDir -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: $outputDir" + } + + Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)" + $releaseArchiveInputs = @($packageFile.FullName) + + $symbolsPackageFile = $null + $newestSnupkgWrite = [datetime]::MinValue + $snupkgCandidates = Get-ChildItem -Path $outputDir -Filter "*.snupkg" + foreach ($candidate in $snupkgCandidates) { + if ($candidate.Name -like "*$version*.snupkg") { + if ($candidate.LastWriteTime -gt $newestSnupkgWrite) { + $newestSnupkgWrite = $candidate.LastWriteTime + $symbolsPackageFile = $candidate + } + } + } + + if ($symbolsPackageFile) { + Write-Log -Level "OK" -Message " Symbols package ready: $($symbolsPackageFile.FullName)" + $releaseArchiveInputs += $symbolsPackageFile.FullName + } + else { + Write-Log -Level "WARN" -Message " Symbols package (.snupkg) not found for version $version." + } + + $sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $packageFile -Force + $sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $symbolsPackageFile -Force + $sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue $releaseArchiveInputs -Force +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetPublish.psm1 b/utils/plugins/DotNet/DotNetPublish.psm1 new file mode 100644 index 0000000..84c4ec2 --- /dev/null +++ b/utils/plugins/DotNet/DotNetPublish.psm1 @@ -0,0 +1,72 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .NET publish plugin for producing application release artifacts. + +.DESCRIPTION + This plugin publishes the configured .NET project into a release output + directory and exposes that published directory to the shared release + context so later release-stage plugins can archive and publish it. +#> + +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" + + $sharedSettings = $Settings.context + $projectFiles = $sharedSettings.projectFiles + $artifactsDirectory = $sharedSettings.artifactsDirectory + $publishProjectPath = $null + + Assert-Command dotnet + + if (-not $sharedSettings.PSObject.Properties['projectFiles'] -or $projectFiles.Count -eq 0) { + throw "DotNetPublish plugin requires project files in the shared context." + } + + if (!(Test-Path $artifactsDirectory)) { + New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null + } + + # The first configured project remains the canonical release artifact source. + $publishProjectPath = $projectFiles[0] + $publishDir = Join-Path $artifactsDirectory ([System.IO.Path]::GetFileNameWithoutExtension($publishProjectPath)) + + if (Test-Path $publishDir) { + Remove-Item -Path $publishDir -Recurse -Force + } + + Write-Log -Level "STEP" -Message "Publishing release artifact..." + dotnet publish $publishProjectPath -c Release -o $publishDir --nologo + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed for $publishProjectPath." + } + + $publishedItems = @(Get-ChildItem -Path $publishDir -Force -ErrorAction SilentlyContinue) + if ($publishedItems.Count -eq 0) { + throw "dotnet publish completed, but no files were produced in: $publishDir" + } + + Write-Log -Level "OK" -Message " Published artifact ready: $publishDir" + + $sharedSettings | Add-Member -NotePropertyName packageFile -NotePropertyValue $null -Force + $sharedSettings | Add-Member -NotePropertyName symbolsPackageFile -NotePropertyValue $null -Force + $sharedSettings | Add-Member -NotePropertyName releaseArchiveInputs -NotePropertyValue @($publishDir) -Force +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetReleaseVersion.psm1 b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 new file mode 100644 index 0000000..66ac1b6 --- /dev/null +++ b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 @@ -0,0 +1,41 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Loads release version into shared context. + +.DESCRIPTION + Dedicated version-loading plugin. It reads .csproj version via + EngineContext helpers and writes Version into the shared runtime context. +#> + +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-DotNetReleaseVersion" + + $shared = $Settings.context + $resolved = Resolve-DotNetReleaseVersion -Plugins @($Settings) -ScriptDir $shared.scriptDir + $projectFiles = @(Resolve-RelativePaths -Value $Settings.projectFiles -BasePath $shared.scriptDir) + + $shared | Add-Member -NotePropertyName version -NotePropertyValue $resolved.version -Force + $shared | Add-Member -NotePropertyName projectFiles -NotePropertyValue $projectFiles -Force + Write-Log -Level "OK" -Message " Release version loaded by DotNetReleaseVersion plugin: $($shared.version)" +} + +Export-ModuleMember -Function Invoke-Plugin + + diff --git a/utils/plugins/DotNet/DotNetTest.psm1 b/utils/plugins/DotNet/DotNetTest.psm1 new file mode 100644 index 0000000..e888a4e --- /dev/null +++ b/utils/plugins/DotNet/DotNetTest.psm1 @@ -0,0 +1,159 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + .NET test plugin for executing automated tests. + +.DESCRIPTION + Resolves one or more .NET test projects (`project` or `projects`), runs tests once + 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. +#> + +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) { + # Same fallback pattern as the other plugins: use the existing shared module if it is already loaded. + 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 "TestRunner" -RequiredCommand "Invoke-TestsWithCoverage" + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $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) + } + } + } + if ($testProjectPaths.Count -eq 0 -and $pluginSettings.project) { + $resolvedPath = Resolve-PluginPath -ConfiguredPath ([string]$pluginSettings.project) -PrimaryBasePath $scriptDir -FallbackBasePaths $fallbackBasePaths + if ($resolvedPath) { + $testProjectPaths.Add($resolvedPath) + } + } + if ($testProjectPaths.Count -eq 0) { + throw "DotNetTest plugin requires 'project' or 'projects' in scriptSettings.json." + } + + $testResultsDir = $null + if (-not [string]::IsNullOrWhiteSpace($testResultsDirSetting)) { + $testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testResultsDirSetting)) + } + elseif ($testProjectPaths.Count -gt 1) { + $testResultsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir "TestResults")) + } + + Write-Log -Level "STEP" -Message "Running tests..." + + # Build a splatted hashtable so optional arguments can be added without duplicating the call site. + $invokeTestParams = @{ + TestProjectPath = @($testProjectPaths) + Silent = $true + } + if ($testResultsDir) { + $invokeTestParams.ResultsDirectory = $testResultsDir + } + + $testResult = Invoke-TestsWithCoverage @invokeTestParams + + if (-not $testResult.Success) { + 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 + } + + Write-Log -Level "OK" -Message " All tests 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)%" +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Npm/NpmBuild.psm1 b/utils/plugins/Npm/NpmBuild.psm1 new file mode 100644 index 0000000..7c43f52 --- /dev/null +++ b/utils/plugins/Npm/NpmBuild.psm1 @@ -0,0 +1,89 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Builds an npm workspace (install + build script). + +.DESCRIPTION + Runs npm ci (or npm install when useCi is false) and npm run build in the + configured workspace root. Requires NpmReleaseVersion to have set + shared npmWorkspaceRoot unless workspaceRoot is configured explicitly. +#> + +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 "NpmBuild plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run." + } + + $useCi = $true + if ($null -ne $pluginSettings.useCi) { + $useCi = [bool]$pluginSettings.useCi + } + + $buildScript = 'build' + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.buildScript)) { + $buildScript = [string]$pluginSettings.buildScript + } + + Push-Location $workspaceRoot + try { + if ($useCi) { + Write-Log -Level "STEP" -Message "Running npm ci in '$workspaceRoot'..." + npm ci + if ($LASTEXITCODE -ne 0) { + throw "npm ci failed with exit code $LASTEXITCODE." + } + } + else { + Write-Log -Level "STEP" -Message "Running npm install in '$workspaceRoot'..." + npm install + if ($LASTEXITCODE -ne 0) { + throw "npm install failed with exit code $LASTEXITCODE." + } + } + + Write-Log -Level "STEP" -Message "Running npm run $buildScript..." + npm run $buildScript + if ($LASTEXITCODE -ne 0) { + throw "npm run $buildScript failed with exit code $LASTEXITCODE." + } + + Write-Log -Level "OK" -Message " npm build completed." + } + finally { + Pop-Location + } +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Npm/NpmJestTest.psm1 b/utils/plugins/Npm/NpmJestTest.psm1 new file mode 100644 index 0000000..82803c7 --- /dev/null +++ b/utils/plugins/Npm/NpmJestTest.psm1 @@ -0,0 +1,83 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + npm/Jest test plugin for the test engine. + +.DESCRIPTION + Runs Jest with coverage via TestRunner.Invoke-NpmJestTestsWithCoverage and publishes + normalized metrics on the shared engine context 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 "TestRunner" -RequiredCommand "Invoke-NpmJestTestsWithCoverage" + Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $scriptDir = $sharedSettings.scriptDir + + Assert-Command npm + + if (-not $pluginSettings.workspaceRoot) { + throw "NpmJestTest plugin requires 'workspaceRoot' in scriptSettings.json." + } + + $workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $scriptDir) + $workspaceRoot = $workspaceRoots[0] + + $testScript = 'test' + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.testScript)) { + $testScript = [string]$pluginSettings.testScript + } + + $coverageDirectory = 'coverage' + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.coverageDirectory)) { + $coverageDirectory = [string]$pluginSettings.coverageDirectory + } + + $testResult = Invoke-NpmJestTestsWithCoverage -WorkspaceRoot $workspaceRoot -TestScript $testScript -CoverageDirectory $coverageDirectory + + if (-not $testResult.Success) { + throw "Tests failed. $($testResult.Error)" + } + + $sharedSettings | Add-Member -NotePropertyName npmWorkspaceRoot -NotePropertyValue $workspaceRoot -Force + $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.PSObject.Properties.Name -contains 'CoverageSummaryFile') -and $testResult.CoverageSummaryFile) { + $sharedSettings | Add-Member -NotePropertyName coverageSummaryFile -NotePropertyValue $testResult.CoverageSummaryFile -Force + } + + Write-Log -Level "OK" -Message " All tests 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)%" +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1 new file mode 100644 index 0000000..7ff8259 --- /dev/null +++ b/utils/plugins/Npm/NpmPublish.psm1 @@ -0,0 +1,118 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + 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. +#> + +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 + + $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) + $workspaceRoot = $workspaceRoots[0] + } + elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) { + $workspaceRoot = [string]$shared.npmWorkspaceRoot + } + else { + throw "NpmPublish plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run." + } + + $registry = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.registry)) { + 'https://registry.npmjs.org' + } + else { + [string]$pluginSettings.registry + } + + $access = if ([string]::IsNullOrWhiteSpace([string]$pluginSettings.access)) { + 'public' + } + else { + [string]$pluginSettings.access + } + + $publishOrder = @() + if ($pluginSettings.publishOrder) { + if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) { + $publishOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) { + $publishOrder = @([string]$pluginSettings.publishOrder) + } + } + + if ($publishOrder.Count -eq 0) { + throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)." + } + + $registryHost = ([uri]$registry).Host + $tempNpmRcPath = Join-Path $workspaceRoot ".npmrc.release-temp" + $npmRcContent = @" +registry=$registry +//$registryHost/:_authToken=$npmApiKey +"@ + + Push-Location $workspaceRoot + try { + Set-Content -Path $tempNpmRcPath -Value $npmRcContent -Encoding UTF8 -NoNewline + + foreach ($packageName in $publishOrder) { + Write-Log -Level "STEP" -Message "Publishing npm package '$packageName'..." + npm publish -w $packageName --access $access --userconfig $tempNpmRcPath + if ($LASTEXITCODE -ne 0) { + throw "Failed to publish npm package '$packageName'." + } + Write-Log -Level "OK" -Message " Published $packageName." + } + + Write-Log -Level "OK" -Message " npm publish completed." + $shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force + } + finally { + if (Test-Path $tempNpmRcPath -PathType Leaf) { + Remove-Item -Path $tempNpmRcPath -Force -ErrorAction SilentlyContinue + } + Pop-Location + } +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Npm/NpmReleaseVersion.psm1 b/utils/plugins/Npm/NpmReleaseVersion.psm1 new file mode 100644 index 0000000..3020c78 --- /dev/null +++ b/utils/plugins/Npm/NpmReleaseVersion.psm1 @@ -0,0 +1,99 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Loads npm workspace release version into shared context. + +.DESCRIPTION + Reads semver from the configured workspace package.json and writes it to + shared context version. Optionally synchronizes version fields across + workspace package manifests before build/publish. +#> + +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-PackageJsonVersionInternal { + param( + [Parameter(Mandatory = $true)] + [string]$PackageJsonPath + ) + + if (-not (Test-Path $PackageJsonPath -PathType Leaf)) { + throw "NpmReleaseVersion: package.json not found at '$PackageJsonPath'." + } + + $json = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json + $version = [string]$json.version + if ([string]::IsNullOrWhiteSpace($version)) { + throw "NpmReleaseVersion: 'version' is missing in '$PackageJsonPath'." + } + + if ($version -notmatch '^\d+\.\d+\.\d+') { + throw "NpmReleaseVersion: version '$version' in '$PackageJsonPath' is not a valid semver." + } + + return $version +} + +function Set-PackageJsonVersionInternal { + param( + [Parameter(Mandatory = $true)] + [string]$PackageJsonPath, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + $raw = Get-Content -Path $PackageJsonPath -Raw -Encoding UTF8 + $json = $raw | ConvertFrom-Json + $json.version = $Version + ($json | ConvertTo-Json -Depth 100) + [Environment]::NewLine | Set-Content -Path $PackageJsonPath -Encoding UTF8 -NoNewline +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" + + $pluginSettings = $Settings + $shared = $Settings.context + + $packageJsonPaths = @(Resolve-RelativePaths -Value $pluginSettings.packageJsonPath -BasePath $shared.scriptDir) + if ($packageJsonPaths.Count -eq 0) { + throw "NpmReleaseVersion plugin requires 'packageJsonPath' in scriptSettings.json." + } + $packageJsonPath = $packageJsonPaths[0] + + $version = Get-PackageJsonVersionInternal -PackageJsonPath $packageJsonPath + $syncWorkspaceVersions = $false + if ($null -ne $pluginSettings.syncWorkspaceVersions) { + $syncWorkspaceVersions = [bool]$pluginSettings.syncWorkspaceVersions + } + + if ($syncWorkspaceVersions) { + $workspaceRoot = Split-Path -Parent $packageJsonPath + $packageManifests = Get-ChildItem -Path (Join-Path $workspaceRoot 'packages') -Recurse -Filter package.json -File -ErrorAction SilentlyContinue + foreach ($manifest in $packageManifests) { + Set-PackageJsonVersionInternal -PackageJsonPath $manifest.FullName -Version $version + } + Write-Log -Level "OK" -Message " Synchronized workspace package versions to $version." + } + + $shared | Add-Member -NotePropertyName version -NotePropertyValue $version -Force + $shared | Add-Member -NotePropertyName npmWorkspaceRoot -NotePropertyValue (Split-Path -Parent $packageJsonPath) -Force + $shared | Add-Member -NotePropertyName npmPackageJsonPath -NotePropertyValue $packageJsonPath -Force + Write-Log -Level "OK" -Message " Release version loaded by NpmReleaseVersion plugin: $version" +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Platform/CoverageBadges.psm1 b/utils/plugins/Platform/CoverageBadges.psm1 new file mode 100644 index 0000000..36ba26a --- /dev/null +++ b/utils/plugins/Platform/CoverageBadges.psm1 @@ -0,0 +1,178 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Coverage badge plugin for the test engine. + +.DESCRIPTION + Reads line/branch/method coverage from shared engine context and writes SVG badges. +#> + +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-BadgeColorInternal { + param( + [double]$percentage, + [psobject]$thresholds + ) + + if ($percentage -ge $thresholds.brightgreen) { return 'brightgreen' } + if ($percentage -ge $thresholds.green) { return 'green' } + if ($percentage -ge $thresholds.yellowgreen) { return 'yellowgreen' } + if ($percentage -ge $thresholds.yellow) { return 'yellow' } + if ($percentage -ge $thresholds.orange) { return 'orange' } + return 'red' +} + +function New-BadgeSvgInternal { + param( + [string]$label, + [string]$value, + [string]$color + ) + + $labelWidth = [math]::Max(($label.Length * 6.5) + 10, 50) + $valueWidth = [math]::Max(($value.Length * 6.5) + 10, 40) + $totalWidth = $labelWidth + $valueWidth + $labelX = $labelWidth / 2 + $valueX = $labelWidth + ($valueWidth / 2) + + $colorMap = @{ + brightgreen = '#4c1' + green = '#97ca00' + yellowgreen = '#a4a61d' + yellow = '#dfb317' + orange = '#fe7d37' + red = '#e05d44' + } + $hexColor = $colorMap[$color] + if (-not $hexColor) { $hexColor = '#9f9f9f' } + + return @" + + $label`: $value + + + + + + + + + + + + + + + $label + + $value + + +"@ +} + +function Get-CoverageMetricsFromSharedContext { + param( + [Parameter(Mandatory = $true)] + $Shared + ) + + $line = $null + $branch = $null + $method = $null + + if ($Shared.PSObject.Properties.Name -contains 'coverageLineRate') { + $line = [double]$Shared.coverageLineRate + } + if ($Shared.PSObject.Properties.Name -contains 'coverageBranchRate') { + $branch = [double]$Shared.coverageBranchRate + } + if ($Shared.PSObject.Properties.Name -contains 'coverageMethodRate') { + $method = [double]$Shared.coverageMethodRate + } + + if ($null -eq $line -and $Shared.PSObject.Properties.Name -contains 'testResult' -and $null -ne $Shared.testResult) { + $line = [double]$Shared.testResult.LineRate + $branch = [double]$Shared.testResult.BranchRate + $method = [double]$Shared.testResult.MethodRate + } + + if ($null -eq $line) { + throw 'CoverageBadges requires coverage metrics on shared context. Run NpmJestTest or DotNetTest first.' + } + + return @{ + line = $line + branch = $branch + method = $method + } +} + +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 + $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 + } + + $thresholds = $pluginSettings.colorThresholds + if ($null -eq $thresholds) { + $thresholds = [pscustomobject]@{ + brightgreen = 80 + green = 60 + yellowgreen = 40 + yellow = 20 + orange = 10 + red = 0 + } + } + + Write-Log -Level "STEP" -Message "Generating coverage badges..." + + 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)'." + } + + $color = Get-BadgeColorInternal -percentage $metricValue -thresholds $thresholds + $svg = New-BadgeSvgInternal -label $badge.label -value "$metricValue%" -color $color + $path = Join-Path $badgesDir $badge.name + $svg | Out-File -FilePath $path -Encoding utf8NoBOM + Write-Log -Level "OK" -Message "$($badge.name): $($badge.label) = $metricValue%" + } + + Write-Log -Level "OK" -Message "Badges generated in: $badgesDir" + Write-Log -Level "STEP" -Message "Commit the badges folder to update README." +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 new file mode 100644 index 0000000..9af816c --- /dev/null +++ b/utils/plugins/Platform/GitHub.psm1 @@ -0,0 +1,244 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + GitHub release plugin. + +.DESCRIPTION + This plugin validates GitHub CLI access, resolves the target + repository, and creates the configured GitHub release using the + shared release artifacts and release notes from CHANGELOG.md. + Release notes must use Keep a Changelog headers: ## [semver] - YYYY-MM-DD + (see ChangelogSupport.psm1). +#> + +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-GitHubRepositoryInternal { + param( + [Parameter(Mandatory = $false)] + [string]$ConfiguredRepository + ) + + $repoSource = $ConfiguredRepository + + if ([string]::IsNullOrWhiteSpace($repoSource)) { + $repoSource = git config --get remote.origin.url + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoSource)) { + throw "Could not determine git remote origin URL." + } + } + + $repoSource = $repoSource.Trim() + + if ($repoSource -match "(?i)github\.com[:/](?[^/]+)/(?[^/.]+)(\.git)?$") { + return "$($matches['owner'])/$($matches['repo'])" + } + + if ($repoSource -match "^(?[^/]+)/(?[^/]+)$") { + return "$($matches['owner'])/$($matches['repo'])" + } + + throw "Could not parse GitHub repo from source: $repoSource. Configure plugins[].repository with 'owner/repo' or a GitHub URL." +} + +function Get-ReleaseNotesInternal { + param( + [Parameter(Mandatory = $true)] + [string]$ReleaseNotesFile, + + [Parameter(Mandatory = $true)] + [string]$Version + ) + + Write-Log -Level "INFO" -Message "Verifying release notes source..." + if (-not (Test-Path $ReleaseNotesFile -PathType Leaf)) { + throw "Release notes source file not found at: $ReleaseNotesFile" + } + + $releaseNotesContent = Get-Content $ReleaseNotesFile -Raw + $releaseNotesVersion = Get-LatestChangelogVersion -ReleaseNotesContent $releaseNotesContent + if ([string]::IsNullOrWhiteSpace($releaseNotesVersion)) { + throw "No version entry found in the configured release notes source. Expected Keep a Changelog header: ## [semver] - YYYY-MM-DD." + } + + if ($releaseNotesVersion -ne $Version) { + throw "Project version ($Version) does not match the latest release notes version ($releaseNotesVersion)." + } + + Write-Log -Level "OK" -Message " Release notes version matches: v$releaseNotesVersion" + + Write-Log -Level "STEP" -Message "Extracting release notes..." + $section = Get-ChangelogReleaseNotesSection -ReleaseNotesContent $releaseNotesContent -Version $Version + + if ([string]::IsNullOrWhiteSpace($section)) { + throw "Release notes entry for version $Version not found. Expected header: ## [$Version] - YYYY-MM-DD." + } + + Write-Log -Level "OK" -Message " Release notes extracted." + return $section +} + +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 "ChangelogSupport" -RequiredCommand "Get-LatestChangelogVersion" + + $pluginSettings = $Settings + $sharedSettings = $Settings.context + $githubTokenEnvVar = $pluginSettings.githubToken + $configuredRepository = $pluginSettings.repository + $releaseNotesFileSetting = $pluginSettings.releaseNotesFile + $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern + $scriptDir = $sharedSettings.scriptDir + $version = $sharedSettings.version + $tag = $sharedSettings.tag + $releaseDir = $sharedSettings.releaseDir + $releaseAssetPaths = @() + + Assert-Command gh + + if ([string]::IsNullOrWhiteSpace($githubTokenEnvVar)) { + throw "GitHub plugin requires 'githubToken' in scriptSettings.json." + } + + $githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar) + if ([string]::IsNullOrWhiteSpace($githubToken)) { + throw "GitHub token is not set. Set '$githubTokenEnvVar' and rerun." + } + + if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { + throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json." + } + + $releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting)) + $releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version + + if ($sharedSettings.PSObject.Properties['releaseAssetPaths'] -and $sharedSettings.releaseAssetPaths) { + $releaseAssetPaths = @($sharedSettings.releaseAssetPaths) + } + elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) { + $releaseAssetPaths = @($sharedSettings.packageFile.FullName) + if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) { + $releaseAssetPaths += $sharedSettings.symbolsPackageFile.FullName + } + } + + $requireReleaseAssets = $true + if ($null -ne $pluginSettings.requireReleaseAssets) { + $requireReleaseAssets = [bool]$pluginSettings.requireReleaseAssets + } + + if ($releaseAssetPaths.Count -eq 0 -and $requireReleaseAssets) { + throw "GitHub release requires at least one prepared release asset (set requireReleaseAssets: false for notes-only npm releases)." + } + + if ($releaseAssetPaths.Count -eq 0) { + Write-Log -Level "INFO" -Message " Notes-only GitHub release (requireReleaseAssets: false)." + } + + $repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository + $releaseTitlePattern = if ([string]::IsNullOrWhiteSpace($releaseTitlePatternSetting)) { + "Release {version}" + } + else { + $releaseTitlePatternSetting + } + $releaseName = $releaseTitlePattern -replace '\{version\}', $version + + Write-Log -Level "INFO" -Message " GitHub repository: $repo" + Write-Log -Level "INFO" -Message " GitHub tag: $tag" + Write-Log -Level "INFO" -Message " GitHub title: $releaseName" + + $previousGhToken = $env:GH_TOKEN + $env:GH_TOKEN = $githubToken + + try { + $ghVersion = & gh --version 2>&1 + if ($ghVersion) { + Write-Log -Level "INFO" -Message " gh version: $($ghVersion[0])" + } + + Write-Log -Level "INFO" -Message " Auth env var: $githubTokenEnvVar (set)" + + $authArgs = @("api", "repos/$repo", "--jq", ".full_name") + $authOutput = & gh @authArgs 2>&1 + $authExitCode = $LASTEXITCODE + + if ($authExitCode -ne 0 -or [string]::IsNullOrWhiteSpace(($authOutput | Out-String))) { + Write-Log -Level "WARN" -Message " gh auth check failed (exit code: $authExitCode)." + if ($authOutput) { + $authOutput | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } + } + + $authStatus = & gh auth status --hostname github.com 2>&1 + if ($authStatus) { + $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." + } + + Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)" + Write-Log -Level "STEP" -Message "Creating GitHub release..." + + $releaseViewArgs = @("release", "view", $tag, "--repo", $repo) + & gh @releaseViewArgs 2>$null + + if ($LASTEXITCODE -eq 0) { + Write-Log -Level "WARN" -Message " Release $tag already exists. Deleting..." + $releaseDeleteArgs = @("release", "delete", $tag, "--repo", $repo, "--yes") + & gh @releaseDeleteArgs + if ($LASTEXITCODE -ne 0) { + throw "Failed to delete existing release $tag." + } + } + + $notesFilePath = Join-Path $releaseDir ("release-notes-{0}.md" -f $version) + + try { + [System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false)) + + $createReleaseArgs = @("release", "create", $tag) + $releaseAssetPaths + @( + "--repo", $repo, + "--title", $releaseName, + "--notes-file", $notesFilePath + ) + & gh @createReleaseArgs + + if ($LASTEXITCODE -ne 0) { + throw "Failed to create GitHub release for tag $tag." + } + } + finally { + if (Test-Path $notesFilePath) { + Remove-Item $notesFilePath -Force + } + } + + Write-Log -Level "OK" -Message " GitHub release created successfully." + $sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force + } + finally { + if ($null -ne $previousGhToken) { + $env:GH_TOKEN = $previousGhToken + } + else { + Remove-Item Env:GH_TOKEN -ErrorAction SilentlyContinue + } + } +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Platform/QualityGate.psm1 b/utils/plugins/Platform/QualityGate.psm1 new file mode 100644 index 0000000..da18d38 --- /dev/null +++ b/utils/plugins/Platform/QualityGate.psm1 @@ -0,0 +1,185 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Quality gate plugin (coverage threshold + optional .NET vulnerability scan). + +.DESCRIPTION + Does not run tests or collect coverage. It reads whatever prior plugins left on the + shared engine context (same object passed to every plugin as .context). + + Line coverage for threshold checks is resolved in order (first present wins): + - qualityLineCoverage (generic; any plugin may set this) + - coverageLineRate (conventional flat metric) + - testResult.LineRate (object from a test plugin; property name is conventional) + + Configure coverageThreshold > 0 to require one of those inputs. With coverageThreshold 0 + and scanVulnerabilities false, the plugin is a no-op. + + When scanVulnerabilities is true, runs dotnet list package --vulnerable on projectFiles. + + Use stageLabel "qualityGate" in scriptSettings.json; plugin: plugins/Platform/QualityGate.psm1 (`"name": "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 Test-VulnerablePackagesInternal { + param( + [Parameter(Mandatory = $true)] + [string[]]$ProjectFiles + ) + + $findings = @() + + foreach ($projectPath in $ProjectFiles) { + Write-Log -Level "STEP" -Message "Checking vulnerable packages: $([System.IO.Path]::GetFileName($projectPath))" + + $output = & dotnet list $projectPath package --vulnerable --include-transitive 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet list package --vulnerable failed for $projectPath." + } + + $outputText = ($output | Out-String) + if ($outputText -match "(?im)\bhas the following vulnerable packages\b" -or $outputText -match "(?im)^\s*>\s+[A-Za-z0-9_.-]+\s") { + $findings += [pscustomobject]@{ + Project = $projectPath + Output = $outputText.Trim() + } + } + } + + return $findings +} + +function Get-LineCoveragePercentFromSharedContext { + param( + [Parameter(Mandatory = $true)] + $Shared + ) + + foreach ($prop in @('qualityLineCoverage', 'coverageLineRate')) { + if ($Shared.PSObject.Properties.Name -contains $prop) { + $raw = $Shared.$prop + if ($null -eq $raw) { continue } + $asString = [string]$raw + if ([string]::IsNullOrWhiteSpace($asString)) { continue } + return [double]$asString + } + } + + if ($Shared.PSObject.Properties.Name -contains 'testResult' -and $null -ne $Shared.testResult) { + $tr = $Shared.testResult + if ($tr.PSObject.Properties.Name -contains 'LineRate') { + return [double]$tr.LineRate + } + } + + return $null +} + +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 + $sharedSettings = $Settings.context + $scriptDir = $sharedSettings.scriptDir + $coverageThresholdSetting = $pluginSettings.coverageThreshold + $failOnVulnerabilitiesSetting = $pluginSettings.failOnVulnerabilities + $scanVulnerabilities = $true + if ($null -ne $pluginSettings.scanVulnerabilities) { + $scanVulnerabilities = [bool]$pluginSettings.scanVulnerabilities + } + + if ($pluginSettings.PSObject.Properties['projectFiles'] -and $null -ne $pluginSettings.projectFiles) { + $projectFiles = @(Resolve-RelativePaths -Value $pluginSettings.projectFiles -BasePath $scriptDir) + } + elseif ($sharedSettings.PSObject.Properties['projectFiles'] -and $null -ne $sharedSettings.projectFiles) { + $projectFiles = @($sharedSettings.projectFiles) + } + else { + $projectFiles = @() + } + + $coverageThreshold = 0 + if ($null -ne $coverageThresholdSetting) { + $coverageThreshold = [double]$coverageThresholdSetting + } + + $needCoverageCheck = $coverageThreshold -gt 0 + if (-not $needCoverageCheck -and -not $scanVulnerabilities) { + Write-Log -Level "INFO" -Message " Quality gate: no checks enabled (coverageThreshold 0, scanVulnerabilities false)." + return + } + + $lineRate = $null + if ($needCoverageCheck) { + $lineRate = Get-LineCoveragePercentFromSharedContext -Shared $sharedSettings + if ($null -eq $lineRate) { + throw "coverageThreshold is $coverageThreshold but shared context has no line coverage. Set one of: qualityLineCoverage, coverageLineRate, or testResult.LineRate (from an earlier plugin)." + } + + Write-Log -Level "STEP" -Message "Checking line coverage threshold against shared context..." + if ($lineRate -lt $coverageThreshold) { + throw "Line coverage $lineRate% is below the configured threshold of $coverageThreshold%." + } + + Write-Log -Level "OK" -Message " Coverage threshold met: $lineRate% >= $coverageThreshold%" + } + else { + Write-Log -Level "INFO" -Message " Coverage threshold check not required (coverageThreshold is 0)." + } + + if (-not $scanVulnerabilities) { + Write-Log -Level "INFO" -Message " Vulnerability scan skipped (scanVulnerabilities is false)." + return + } + + Assert-Command dotnet + + $failOnVulnerabilities = $true + if ($null -ne $failOnVulnerabilitiesSetting) { + $failOnVulnerabilities = [bool]$failOnVulnerabilitiesSetting + } + + if ($projectFiles.Count -eq 0) { + throw "QualityGate requires projectFiles when scanVulnerabilities is true." + } + + $vulnerabilities = Test-VulnerablePackagesInternal -ProjectFiles $projectFiles + + if ($vulnerabilities.Count -eq 0) { + Write-Log -Level "OK" -Message " No vulnerable packages detected." + return + } + + foreach ($finding in $vulnerabilities) { + Write-Log -Level "WARN" -Message " Vulnerable packages detected in $([System.IO.Path]::GetFileName($finding.Project))" + $finding.Output -split "`r?`n" | ForEach-Object { + if (-not [string]::IsNullOrWhiteSpace($_)) { + Write-Log -Level "WARN" -Message " $_" + } + } + } + + if ($failOnVulnerabilities) { + throw "Vulnerable packages were detected and failOnVulnerabilities is enabled." + } + + Write-Log -Level "WARN" -Message "Vulnerable packages detected, but failOnVulnerabilities is disabled." +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1 new file mode 100644 index 0000000..adfb262 --- /dev/null +++ b/utils/plugins/Platform/ReleasePublishGuard.psm1 @@ -0,0 +1,167 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Central gate for publish-stage plugins (DotNetDockerPush, DotNetHelmPush, GitHub, DotNetNuGet, NpmPublish). + +.DESCRIPTION + Place this plugin immediately before any publish plugins in scriptSettings.json. It sets + shared context skipPublishPlugins to false when all configured requirements pass, or true + when they do not (whenRequirementsNotMet: skip). Publish plugins no longer use per-plugin + branch lists; put allowed branches here instead. + + Typical checks: allowed branches, optional clean working tree, exact semver tag on HEAD, + tag version vs DotNetReleaseVersion, optional push tag to remote. + + The engine preflight no longer reads git tags; this plugin sets context.tag from the + git tag on HEAD when required. Shared context version always remains from DotNetReleaseVersion. +#> + +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-ExactTagOnHeadSilentlyInternal { + $raw = & git describe --tags --exact-match HEAD 2>&1 + if ($LASTEXITCODE -ne 0) { + return $null + } + $s = ($raw | Out-String).Trim() + if ([string]::IsNullOrWhiteSpace($s)) { + return $null + } + return $s +} + +function Invoke-NotMetInternal { + param( + [Parameter(Mandatory = $true)] + $Shared, + + [Parameter(Mandatory = $true)] + [string]$When, + + [Parameter(Mandatory = $true)] + [string]$Reason + ) + + $Shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $true -Force + if ($When -eq 'fail') { + Write-Log -Level "ERROR" -Message "ReleasePublishGuard: $Reason" + exit 1 + } + + Write-Log -Level "WARN" -Message " Publish suppressed: $Reason" +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "PluginSupport" -RequiredCommand "Get-PluginBranches" + + Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Get-GitStatusShort" + Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Test-RemoteTagExists" + Import-PluginDependency -ModuleName "GitTools" -RequiredCommand "Push-TagToRemote" + + $pluginSettings = $Settings + $shared = $Settings.context + $when = 'skip' + if ($null -ne $pluginSettings.whenRequirementsNotMet) { + $when = [string]$pluginSettings.whenRequirementsNotMet + } + if ($when -notin @('skip', 'fail')) { + throw "ReleasePublishGuard: whenRequirementsNotMet must be 'skip' or 'fail'." + } + + $shared | Add-Member -NotePropertyName skipPublishPlugins -NotePropertyValue $false -Force + + Write-Log -Level "STEP" -Message "Release publish guard..." + + $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." + return + } + + $requireClean = $false + if ($null -ne $pluginSettings.requireCleanWorkingTree) { + $requireClean = [bool]$pluginSettings.requireCleanWorkingTree + } + if ($requireClean) { + $dirtyRaw = Get-GitStatusShort + if (-not [string]::IsNullOrWhiteSpace([string]$dirtyRaw)) { + Invoke-NotMetInternal -Shared $shared -When $when -Reason "working tree is not clean (requireCleanWorkingTree is true)." + return + } + } + + $requireTag = $true + if ($null -ne $pluginSettings.requireExactTagOnHead) { + $requireTag = [bool]$pluginSettings.requireExactTagOnHead + } + + $tag = $null + if ($requireTag) { + $tag = Get-ExactTagOnHeadSilentlyInternal + if ([string]::IsNullOrWhiteSpace($tag)) { + Invoke-NotMetInternal -Shared $shared -When $when -Reason "no exact semver tag on HEAD (git describe --tags --exact-match)." + return + } + + if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') { + Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag '$tag' must match vX.Y.Z." + return + } + + $tagVersion = $Matches[1] + $mustMatch = $true + if ($null -ne $pluginSettings.tagVersionMustMatchReleaseVersion) { + $mustMatch = [bool]$pluginSettings.tagVersionMustMatchReleaseVersion + } + elseif ($null -ne $pluginSettings.tagVersionMustMatchNpmRelease) { + $mustMatch = [bool]$pluginSettings.tagVersionMustMatchNpmRelease + } + elseif ($null -ne $pluginSettings.tagVersionMustMatchDotNetRelease) { + $mustMatch = [bool]$pluginSettings.tagVersionMustMatchDotNetRelease + } + if ($mustMatch -and $tagVersion -ne [string]$shared.version) { + Invoke-NotMetInternal -Shared $shared -When $when -Reason "tag version $tagVersion does not match release version $($shared.version)." + return + } + + $shared | Add-Member -NotePropertyName tag -NotePropertyValue $tag -Force + } + + $ensureRemote = $true + if ($null -ne $pluginSettings.ensureTagOnRemote) { + $ensureRemote = [bool]$pluginSettings.ensureTagOnRemote + } + if ($ensureRemote -and $requireTag -and -not [string]::IsNullOrWhiteSpace($tag)) { + $remote = 'origin' + if (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.remoteName)) { + $remote = [string]$pluginSettings.remoteName + } + + Write-Log -Level "STEP" -Message "Verifying tag on remote '$remote'..." + if (-not (Test-RemoteTagExists -Tag $tag -Remote $remote)) { + Write-Log -Level "WARN" -Message " Tag $tag not on remote. Pushing..." + Push-TagToRemote -Tag $tag -Remote $remote + } + else { + Write-Log -Level "OK" -Message " Tag exists on remote." + } + } + + Write-Log -Level "OK" -Message " Publish guard passed; publish plugins will run." +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/tools/Enable-ModelsNullable.ps1 b/utils/tools/Enable-ModelsNullable.ps1 new file mode 100644 index 0000000..1826a4a --- /dev/null +++ b/utils/tools/Enable-ModelsNullable.ps1 @@ -0,0 +1,34 @@ +#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/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 b/utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 new file mode 100644 index 0000000..c144f6a --- /dev/null +++ b/utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 @@ -0,0 +1,248 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Amends the latest commit, recreates its associated tag, and force pushes both to remote. + +.DESCRIPTION + This script performs the following operations: + 1. Gets the last commit and verifies it has an associated tag + 2. Stages all pending changes + 3. Amends the latest commit (keeps existing message) + 4. Deletes and recreates the tag on the amended commit + 5. Force pushes the branch and tag to remote + + All configuration is in scriptSettings.json. + +.PARAMETER DryRun + If specified, shows what would be done without making changes. + +.EXAMPLE + pwsh -File .\Force-AmendTaggedCommit.ps1 + +.EXAMPLE + pwsh -File .\Force-AmendTaggedCommit.ps1 -DryRun + +.NOTES + CONFIGURATION (scriptSettings.json): + - git.remote: Remote name to push to (default: "origin") + - git.confirmBeforeAmend: Prompt before amending (default: true) + - git.confirmWhenNoChanges: Prompt if no pending changes (default: true) +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [switch]$DryRun +) + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$srcDir = Split-Path (Split-Path $scriptDir -Parent) -Parent +$modulesDir = Join-Path $srcDir 'modules' + +#region Import Modules + +$scriptConfigModulePath = Join-Path $modulesDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" + exit 1 +} + +# Import shared GitTools module (git operations used by this script) +$gitToolsModulePath = Join-Path $modulesDir "GitTools.psm1" +if (-not (Test-Path $gitToolsModulePath)) { + Write-Error "GitTools module not found at: $gitToolsModulePath" + exit 1 +} + +$loggingModulePath = Join-Path $modulesDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} + +Import-Module $scriptConfigModulePath -Force +Import-Module $loggingModulePath -Force +Import-Module $gitToolsModulePath -Force + +#endregion + +#region Helpers + +function Select-PreferredHeadTag { + param( + [Parameter(Mandatory = $true)] + [string[]]$Tags + ) + + # Pick the latest tag on HEAD by git's own ordering (no tag-name parsing assumptions). + $ordered = (& git tag --points-at HEAD --sort=-creatordate 2>$null) + if ($LASTEXITCODE -eq 0 -and $ordered) { + $orderedTags = @($ordered | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) + if ($orderedTags.Count -gt 0) { + return $orderedTags[0] + } + } + + # Fallback: keep script functional even if sorting is unavailable. + return $Tags[0] +} + +#endregion + +#region Load Settings + +$settings = Get-ScriptSettings -ScriptDir $scriptDir + +#endregion + +#region Configuration + +# Git configuration with safe defaults when settings are omitted +$Remote = if ($settings.git.remote) { $settings.git.remote } else { "origin" } +$ConfirmBeforeAmend = if ($null -ne $settings.git.confirmBeforeAmend) { $settings.git.confirmBeforeAmend } else { $true } +$ConfirmWhenNoChanges = if ($null -ne $settings.git.confirmWhenNoChanges) { $settings.git.confirmWhenNoChanges } else { $true } + +#endregion + +#region Validate CLI Dependencies + +Assert-Command git + +#endregion + +#region Main + +Write-Log -Level "INFO" -Message "========================================" +Write-Log -Level "INFO" -Message "Force Amend Tagged Commit Script" +Write-Log -Level "INFO" -Message "========================================" + +if ($DryRun) { + Write-Log -Level "WARN" -Message "*** DRY RUN MODE - No changes will be made ***" +} + +#region Preflight + +# 1. Detect current branch +$Branch = Get-CurrentBranch + +# 2. Read HEAD commit details +Write-LogStep "Getting last commit..." +$CommitMessage = Get-HeadCommitMessage +$CommitHash = Get-HeadCommitHash -Short +Write-Log -Level "INFO" -Message "Commit: $CommitHash - $CommitMessage" + +# 3. Ensure HEAD has at least one tag +Write-LogStep "Finding tag on last commit..." +$tags = Get-HeadTags +if ($tags.Count -eq 0) { + Write-Error "No tag found on the last commit ($CommitHash). This script requires the last commit to have an associated tag." + exit 1 +} + +# If multiple tags exist, choose the latest one on HEAD by git ordering. +if ($tags.Count -gt 1) { + Write-Log -Level "WARN" -Message "Multiple tags found on HEAD: $($tags -join ', ')" +} +$TagName = Select-PreferredHeadTag -Tags $tags +Write-Log -Level "OK" -Message "Found tag: $TagName" + +# 4. Inspect pending changes before amend +Write-LogStep "Checking pending changes..." +$Status = Get-GitStatusShort +if (-not [string]::IsNullOrWhiteSpace($Status)) { + Write-Log -Level "INFO" -Message "Pending changes:" + $Status -split "`r?`n" | ForEach-Object { Write-Log -Level "INFO" -Message " $_" } +} +else { + Write-Log -Level "WARN" -Message "No pending changes found" + if ($ConfirmWhenNoChanges -and -not $DryRun) { + $confirm = Read-Host "`n No changes to amend. Continue to recreate tag and force push? (y/N)" + if ($confirm -ne 'y' -and $confirm -ne 'Y') { + Write-Log -Level "WARN" -Message "Aborted by user" + exit 0 + } + } +} + +# 5. Show operation summary and request explicit confirmation +Write-Log -Level "INFO" -Message "----------------------------------------" +Write-Log -Level "INFO" -Message "Summary of operations:" +Write-Log -Level "INFO" -Message "----------------------------------------" +Write-Log -Level "INFO" -Message "Branch: $Branch" +Write-Log -Level "INFO" -Message "Commit: $CommitHash" +Write-Log -Level "INFO" -Message "Tag: $TagName" +Write-Log -Level "INFO" -Message "Remote: $Remote" +Write-Log -Level "INFO" -Message "----------------------------------------" + +if ($ConfirmBeforeAmend -and -not $DryRun) { + $confirm = Read-Host " Proceed with amend and force push? (y/N)" + if ($confirm -ne 'y' -and $confirm -ne 'Y') { + Write-Log -Level "WARN" -Message "Aborted by user" + exit 0 + } +} + +#endregion + +#region Amend And Push + +# 6. Stage all changes to include them in amended commit +Write-LogStep "Staging all changes..." +if (-not $DryRun) { + Add-AllChanges +} +Write-Log -Level "OK" -Message "All changes staged" + +# 7. Amend HEAD commit while preserving commit message +Write-LogStep "Amending commit..." +if (-not $DryRun) { + Update-HeadCommitNoEdit +} +Write-Log -Level "OK" -Message "Commit amended" + +# 8. Move existing local tag to the amended commit +Write-LogStep "Deleting local tag '$TagName'..." +if (-not $DryRun) { + Remove-LocalTag -Tag $TagName +} +Write-Log -Level "OK" -Message "Local tag deleted" + +# 9. Recreate the same tag on new HEAD +Write-LogStep "Recreating tag '$TagName' on amended commit..." +if (-not $DryRun) { + New-LocalTag -Tag $TagName +} +Write-Log -Level "OK" -Message "Tag recreated" + +# 10. Force push updated branch history +Write-LogStep "Force pushing branch '$Branch' to $Remote..." +if (-not $DryRun) { + Push-BranchToRemote -Branch $Branch -Remote $Remote -Force +} +Write-Log -Level "OK" -Message "Branch force pushed" + +# 11. Force push moved tag +Write-LogStep "Force pushing tag '$TagName' to $Remote..." +if (-not $DryRun) { + Push-TagToRemote -Tag $TagName -Remote $Remote -Force +} +Write-Log -Level "OK" -Message "Tag force pushed" + +#endregion + +#region Summary + +Write-Log -Level "OK" -Message "========================================" +Write-Log -Level "OK" -Message "Operation completed successfully!" +Write-Log -Level "OK" -Message "========================================" + +# Show resulting HEAD commit after amend +Write-Log -Level "INFO" -Message "Final state:" +$finalLog = Get-HeadCommitOneLine +Write-Log -Level "INFO" -Message $finalLog + +#endregion + +#endregion diff --git a/utils/tools/Force-AmendTaggedCommit/scriptSettings.json b/utils/tools/Force-AmendTaggedCommit/scriptSettings.json new file mode 100644 index 0000000..df73911 --- /dev/null +++ b/utils/tools/Force-AmendTaggedCommit/scriptSettings.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Configuration for Force-AmendTaggedCommit.ps1", + + "git": { + "remote": "origin", + "confirmBeforeAmend": true, + "confirmWhenNoChanges": true + }, + + "_comments": { + "git": { + "remote": "Remote name used for force-pushing branch and tag", + "confirmBeforeAmend": "Ask for confirmation before amend + force-push operations", + "confirmWhenNoChanges": "Ask for confirmation when there are no pending changes to amend" + } + } +} diff --git a/utils/tools/Polish-PodmanClientSources.ps1 b/utils/tools/Polish-PodmanClientSources.ps1 new file mode 100644 index 0000000..2c77b27 --- /dev/null +++ b/utils/tools/Polish-PodmanClientSources.ps1 @@ -0,0 +1,117 @@ +#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 new file mode 100644 index 0000000..80082c3 --- /dev/null +++ b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 @@ -0,0 +1,358 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Refreshes a local maksit-repoutils copy from GitHub. + +.DESCRIPTION + This script clones the configured repository into a temporary directory, + refreshes the parent directory of this script, preserves existing + scriptSettings.json files in subfolders, and copies the cloned source + contents into that parent directory. + + All configuration is stored in scriptSettings.json. + +.EXAMPLE + pwsh -File .\Update-RepoUtils.ps1 + +.NOTES + CONFIGURATION (scriptSettings.json): + - dryRun: If true, logs the planned update without modifying files + - repository.url: Git repository to clone + - repository.sourceSubdirectory: Folder copied into the target directory + - repository.preserveFileName: Existing file name to preserve in subfolders + - repository.cloneDepth: Depth used for git clone + - repository.skippedRelativeDirectories: Relative directories to exclude from phase-two refresh +#> + +[CmdletBinding()] +param( + [switch]$ContinueAfterSelfUpdate, + [string]$TargetDirectoryOverride, + [string]$ClonedSourceDirectoryOverride, + [string]$TemporaryRootOverride +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$srcDir = Split-Path (Split-Path $scriptDir -Parent) -Parent +$modulesDir = Join-Path $srcDir 'modules' + +# Refresh the src directory that contains modules, engines, plugins, and tools. +$targetDirectory = if ([string]::IsNullOrWhiteSpace($TargetDirectoryOverride)) { + $srcDir +} +else { + [System.IO.Path]::GetFullPath($TargetDirectoryOverride) +} +$currentScriptPath = [System.IO.Path]::GetFullPath($MyInvocation.MyCommand.Path) +$selfUpdateDirectory = [System.IO.Path]::Combine('tools', 'Update-RepoUtils') + +function ConvertTo-NormalizedRelativePath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $normalizedPath = $Path.Replace('/', [System.IO.Path]::DirectorySeparatorChar).Replace('\', [System.IO.Path]::DirectorySeparatorChar) + return $normalizedPath.TrimStart('.', [System.IO.Path]::DirectorySeparatorChar).TrimEnd([System.IO.Path]::DirectorySeparatorChar) +} + +function Test-IsInRelativeDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$RelativePath, + + [Parameter(Mandatory = $true)] + [string[]]$Directories + ) + + $normalizedRelativePath = ConvertTo-NormalizedRelativePath -Path $RelativePath + foreach ($directory in $Directories) { + $normalizedDirectory = ConvertTo-NormalizedRelativePath -Path $directory + if ([string]::IsNullOrWhiteSpace($normalizedDirectory)) { + continue + } + + if ( + $normalizedRelativePath.Equals($normalizedDirectory, [System.StringComparison]::OrdinalIgnoreCase) -or + $normalizedRelativePath.StartsWith($normalizedDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) + ) { + return $true + } + } + + return $false +} + +#region Import Modules + +$scriptConfigModulePath = Join-Path $modulesDir "ScriptConfig.psm1" +if (-not (Test-Path $scriptConfigModulePath)) { + Write-Error "ScriptConfig module not found at: $scriptConfigModulePath" + exit 1 +} + +$loggingModulePath = Join-Path $modulesDir "Logging.psm1" +if (-not (Test-Path $loggingModulePath)) { + Write-Error "Logging module not found at: $loggingModulePath" + exit 1 +} + +Import-Module $scriptConfigModulePath -Force +Import-Module $loggingModulePath -Force + +#endregion + +#region Load Settings + +$settings = Get-ScriptSettings -ScriptDir $scriptDir + +#endregion + +#region Configuration + +$repositoryUrl = $settings.repository.url +$dryRun = if ($null -ne $settings.dryRun) { [bool]$settings.dryRun } else { $false } +$sourceSubdirectory = if ($settings.repository.sourceSubdirectory) { $settings.repository.sourceSubdirectory } else { 'src' } +$preserveFileName = if ($settings.repository.preserveFileName) { $settings.repository.preserveFileName } else { 'scriptSettings.json' } +$cloneDepth = if ($settings.repository.cloneDepth) { [int]$settings.repository.cloneDepth } else { 1 } +[string[]]$skippedRelativeDirectories = if ($settings.repository.skippedRelativeDirectories) { + @( + $settings.repository.skippedRelativeDirectories | + ForEach-Object { + ConvertTo-NormalizedRelativePath -Path ([string]$_) + } + ) +} +else { + @( + [System.IO.Path]::Combine('engines', 'release', 'custom'), + [System.IO.Path]::Combine('engines', 'test', 'custom') + ) +} + +#endregion + +#region Validate CLI Dependencies + +Assert-Command git +Assert-Command pwsh + +if ([string]::IsNullOrWhiteSpace($repositoryUrl)) { + Write-Error "repository.url is required in scriptSettings.json." + exit 1 +} + +#endregion + +#region Main + +Write-Log -Level "INFO" -Message "========================================" +Write-Log -Level "INFO" -Message "Update RepoUtils Script" +Write-Log -Level "INFO" -Message "========================================" +Write-Log -Level "INFO" -Message "Target directory: $targetDirectory" +Write-Log -Level "INFO" -Message "Dry run: $dryRun" + +$ownsTemporaryRoot = [string]::IsNullOrWhiteSpace($TemporaryRootOverride) +$temporaryRoot = if ($ownsTemporaryRoot) { + Join-Path ([System.IO.Path]::GetTempPath()) ("maksit-repoutils-update-" + [System.Guid]::NewGuid().ToString('N')) +} +else { + [System.IO.Path]::GetFullPath($TemporaryRootOverride) +} + +try { + $clonedSourceDirectory = if ([string]::IsNullOrWhiteSpace($ClonedSourceDirectoryOverride)) { + Write-LogStep "Cloning latest repository snapshot..." + & git clone --depth $cloneDepth $repositoryUrl $temporaryRoot + if ($LASTEXITCODE -ne 0) { + throw "git clone failed with exit code $LASTEXITCODE." + } + Write-Log -Level "OK" -Message "Repository cloned" + + Join-Path $temporaryRoot $sourceSubdirectory + } + else { + [System.IO.Path]::GetFullPath($ClonedSourceDirectoryOverride) + } + + if (-not (Test-Path -Path $clonedSourceDirectory -PathType Container)) { + throw "The cloned repository does not contain the expected source directory: $clonedSourceDirectory" + } + + if (-not $ContinueAfterSelfUpdate) { + if ($dryRun) { + Write-LogStep "Dry run self-update summary" + Write-Log -Level "INFO" -Message "Would refresh shared modules and $selfUpdateDirectory before relaunching the updater" + } + else { + Write-LogStep "Refreshing updater files..." + $selfUpdateFiles = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File | + Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName) + $isRootFile = -not $relativePath.Contains([System.IO.Path]::DirectorySeparatorChar) + $isUpdaterFile = $relativePath.StartsWith($selfUpdateDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) + + $_.Name -ne $preserveFileName -and + ($isRootFile -or $isUpdaterFile) + } + + foreach ($sourceFile in $selfUpdateFiles) { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName) + $destinationPath = Join-Path $targetDirectory $relativePath + $destinationDirectory = Split-Path -Parent $destinationPath + if (-not (Test-Path -Path $destinationDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + + Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force + } + + Write-Log -Level "OK" -Message "Updater files refreshed" + } + + if ($dryRun) { + Write-LogStep "Dry run bootstrap completed" + Write-Log -Level "INFO" -Message "Continuing with phase two in the current process because no files were changed" + } + else { + Write-LogStep "Relaunching the updated updater..." + & pwsh -File $currentScriptPath ` + -ContinueAfterSelfUpdate ` + -TargetDirectoryOverride $targetDirectory ` + -ClonedSourceDirectoryOverride $clonedSourceDirectory ` + -TemporaryRootOverride $temporaryRoot + if ($LASTEXITCODE -ne 0) { + throw "Relaunched updater failed with exit code $LASTEXITCODE." + } + + Write-Log -Level "OK" -Message "Bootstrap phase completed" + return + } + } + + $preservedFiles = @() + [string[]]$updatePhaseSkippedDirectories = @($skippedRelativeDirectories) + $selfUpdateDirectory + $existingPreservedFiles = Get-ChildItem -Path $targetDirectory -Recurse -File -Filter $preserveFileName -ErrorAction SilentlyContinue + if ($existingPreservedFiles) { + foreach ($file in $existingPreservedFiles) { + $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $file.FullName) + $backupPath = Join-Path $temporaryRoot ("preserved-" + ($relativePath -replace '[\\/:*?""<>|]', '_')) + $preservedFiles += [pscustomobject]@{ + RelativePath = $relativePath + BackupPath = $backupPath + } + + if (-not $dryRun) { + Copy-Item -Path $file.FullName -Destination $backupPath -Force + } + } + Write-Log -Level "OK" -Message "Preserved $($preservedFiles.Count) existing $preserveFileName file(s)" + } + else { + Write-Log -Level "WARN" -Message "No existing $preserveFileName files found in subfolders" + } + + if ($dryRun) { + Write-LogStep "Dry run summary" + Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName files" + Write-Log -Level "INFO" -Message "Would skip phase-two refresh for: $($updatePhaseSkippedDirectories -join ', ')" + Write-Log -Level "INFO" -Message "Would copy refreshed files from: $clonedSourceDirectory" + if ($preservedFiles.Count -gt 0) { + $preservedList = ($preservedFiles | ForEach-Object { $_.RelativePath }) -join ", " + Write-Log -Level "INFO" -Message "Would restore preserved files: $preservedList" + } + Write-Log -Level "OK" -Message "Dry run completed. No files were modified." + return + } + + Write-LogStep "Cleaning target directory..." + $filesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -File | + Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $_.FullName) + $isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories + + $_.Name -ne $preserveFileName -and + -not $isInSkippedDirectory + } + + foreach ($file in $filesToRemove) { + Remove-Item -Path $file.FullName -Force + } + + $directoriesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -Directory | + Sort-Object { $_.FullName.Length } -Descending + + foreach ($directory in $directoriesToRemove) { + $relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $directory.FullName) + if (Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories) { + continue + } + + $remainingItems = Get-ChildItem -Path $directory.FullName -Force -ErrorAction SilentlyContinue + if (-not $remainingItems) { + Remove-Item -Path $directory.FullName -Force + } + } + Write-Log -Level "OK" -Message "Target directory cleaned" + + Write-LogStep "Copying refreshed source files..." + $sourceFilesToCopy = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File | + Where-Object { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName) + $isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories + + -not $isInSkippedDirectory + } + + foreach ($sourceFile in $sourceFilesToCopy) { + $relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName) + $destinationPath = Join-Path $targetDirectory $relativePath + $destinationDirectory = Split-Path -Parent $destinationPath + if (-not (Test-Path -Path $destinationDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + } + + Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force + } + + foreach ($skippedDirectory in $updatePhaseSkippedDirectories) { + $skippedSourcePath = Join-Path $clonedSourceDirectory $skippedDirectory + if (Test-Path -Path $skippedSourcePath) { + Write-Log -Level "INFO" -Message "Skipped refresh for $skippedDirectory" + } + } + Write-Log -Level "OK" -Message "Source files copied" + + if ($preservedFiles.Count -gt 0) { + foreach ($preservedFile in $preservedFiles) { + if (-not (Test-Path -Path $preservedFile.BackupPath -PathType Leaf)) { + continue + } + + $restorePath = Join-Path $targetDirectory $preservedFile.RelativePath + $restoreDirectory = Split-Path -Parent $restorePath + if (-not (Test-Path -Path $restoreDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $restoreDirectory -Force | Out-Null + } + + Copy-Item -Path $preservedFile.BackupPath -Destination $restorePath -Force + } + Write-Log -Level "OK" -Message "$preserveFileName files restored" + } + + Write-Log -Level "OK" -Message "========================================" + Write-Log -Level "OK" -Message "Update completed successfully!" + Write-Log -Level "OK" -Message "========================================" +} +finally { + if ($ownsTemporaryRoot -and (Test-Path -Path $temporaryRoot)) { + Remove-Item -Path $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +#endregion diff --git a/utils/tools/Update-RepoUtils/scriptSettings.json b/utils/tools/Update-RepoUtils/scriptSettings.json new file mode 100644 index 0000000..de67aab --- /dev/null +++ b/utils/tools/Update-RepoUtils/scriptSettings.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Update RepoUtils Script Settings", + "description": "Configuration for the Update-RepoUtils utility.", + "dryRun": true, + "repository": { + "url": "https://github.com/MAKS-IT-COM/maksit-repoutils.git", + "sourceSubdirectory": "src", + "preserveFileName": "scriptSettings.json", + "cloneDepth": 1, + "skippedRelativeDirectories": [ + "engines/release/custom", + "engines/test/custom" + ] + } +}