(bugfix): improved build pipeline, more generic helm chart, minor bug fixing, tests

This commit is contained in:
Maksym Sadovnychyy 2026-04-03 14:20:39 +02:00
parent df835155ba
commit 0f4c4cbeac
62 changed files with 3189 additions and 1834 deletions

3
.gitignore vendored
View File

@ -264,4 +264,5 @@ __pycache__/
# SonarQube
.scannerwork/
.sonarlint/
.sonarlint/
src/MaksIT.WebUI/public/pdf.worker.min.mjs

74
CHANGELOG.md Normal file
View File

@ -0,0 +1,74 @@
# 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).
## [3.3.4] - 2026-04-01
### Added
- `MaksIT.Webapi.Tests`: service-level unit tests (settings, cache, identity, agent, account, certs flow) and domain tests for `Settings`.
- Postman collections under `src/Postman` updated to match current `MaksIT.Webapi` routes, JWT flow, and cache endpoints.
### Fixed
- WebUI Terms of Service (Let's Encrypt): PDF viewer loads `pdfjs-dist` worker from a Vite-bundled asset (`pdf.worker.min.mjs?url`) so rendering works in dev and production instead of failing on missing or wrong worker URLs.
- `AccountService.PatchAccountAsync` returns the account built from the cache after reload, not a stale in-memory instance.
## [3.3.3] - 2025-12-20
### Changed
- Relicensed project from GPL-3.0 to Apache-2.0.
## [3.3.2] - 2025-12-20
### Changed
- Minimal Helm chart and documentation improvements.
## [3.3.1] - 2025-11-22
### Changed
- Public release following the v3.3.0 pre-release.
## [3.3.0] - 2025-11-15
### Changed
- Pre-release of the v3.3.x line.
## [3.2.0] - 2025-09-11
### Added
- New WebUI with authentication.
## [3.1.0] - 2024-08-11
### Changed
- Stabilized release following v3.0.0.
## [3.0.0] - 2024-05-31
### Added
- WebAPI and containerization.
## [2.0.0] - 2019-11-01
### Changed
- Dependency injection pattern implementation.
## [1.0.0] - 2019-06-29
### Added
- Initial release.

53
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,53 @@
# Contributing to MaksIT.CertsUI
Thank you for your interest in improving this project. This document describes how to set up a development environment, what we expect from contributions, and where to get help.
## License
By contributing, you agree that your contributions will be licensed under the same terms as the project. See [LICENSE.md](LICENSE.md) (Apache License 2.0).
## What to contribute
Useful contributions include bug fixes, documentation improvements, Helm chart updates, and small, focused feature changes that fit the architecture described in [README.md](README.md) (single agent, HTTP-01, and related limitations).
Large or architectural changes are best discussed first (see [Contact](#contact)) so effort aligns with project goals.
## Development setup
### Prerequisites
- [.NET SDK](https://dotnet.microsoft.com/download) compatible with the `TargetFramework` values in the `.csproj` files under `src/` (the main solution currently targets **.NET 10**).
- Optional but recommended for end-to-end checks: **Docker** or **Podman**, as in the README installation sections.
- **Visual Studio 2022** or another editor with C# support works well; the solution file is `src/MaksIT.CertsUI.sln`.
### Build
From the repository root:
```bash
dotnet build src/MaksIT.CertsUI.sln -c Release
```
Use `Debug` while iterating locally if you prefer.
### Run the stack locally
Follow [README.md](README.md) for Podman Compose, Docker Compose, or Kubernetes (Helm). That is the supported way to exercise the WebAPI, WebUI, and reverse proxy together.
There is no separate automated test project in this repository today; manual verification through the WebUI and your compose or cluster setup is the practical check for most changes.
## Pull requests
1. **Branch from the branch the maintainers use for integration** (often `dev` or `main`—check the default on the host repository).
2. **Keep changes scoped**—one logical fix or feature per PR makes review and history easier.
3. **Describe the change** in the PR: what problem it solves, how you tested it, and any operational impact (config, Helm values, images).
4. **Update [CHANGELOG.md](CHANGELOG.md)** when the change is user-visible (behavior, security, deployment, or notable docs). Add entries under a new version heading or an `[Unreleased]` section at the top, following the existing [Keep a Changelog](https://keepachangelog.com/) style.
5. **Avoid unrelated formatting or drive-by refactors** in the same PR as functional changes.
## Security issues
Please do not open a public issue for undisclosed security vulnerabilities. Report them privately using the contact in [README.md](README.md) (Contact section) so they can be handled responsibly.
## Contact
Questions and coordination: see **Contact** in [README.md](README.md).

153
README.md
View File

@ -1,5 +1,7 @@
# MaksIT.CertsUI Modern container-native ACME client with a full WebUI experience
![Line Coverage](assets/badges/coverage-lines.svg) ![Branch Coverage](assets/badges/coverage-branches.svg) ![Method Coverage](assets/badges/coverage-methods.svg)
MaksIT.CertsUI is a powerful, container-native ACMEv2 client built to simplify and automate the entire lifecycle of HTTPS certificates issued by Lets Encrypt. It is an independent, unofficial project and is not affiliated with or endorsed by Lets Encrypt or ISRG.
Designed for modern infrastructure, it combines a robust WebAPI, intuitive WebUI, and lightweight edge Agent to deliver fully automated certificate issuance, renewal, and deployment across Docker, Podman, and Kubernetes environments. MaksIT.CertsUI supports the HTTP-01 challenge and follows the official [Lets Encrypt guidelines](https://letsencrypt.org/docs/) while implementing recommended security and operational best practices.
@ -18,7 +20,8 @@ If you find this project useful, please consider supporting its development:
- [MaksIT.CertsUI Modern container-native ACME client with a full WebUI experience](#maksitcertsui--modern-container-native-acme-client-with-a-full-webui-experience)
- [Table of Contents](#table-of-contents)
- [Versions History](#versions-history)
- [Changelog](#changelog)
- [Contributing](#contributing)
- [Architecture](#architecture)
- [Current Limitations](#current-limitations)
- [Architecture Scheme](#architecture-scheme)
@ -43,18 +46,13 @@ If you find this project useful, please consider supporting its development:
- [Contact](#contact)
## Versions History
## Changelog
* 29 Jun, 2019 - V1.0.0
* 01 Nov, 2019 - V2.0.0 (Dependency Injection pattern implementation)
* 31 May, 2024 - V3.0.0 (Webapi and containerization)
* 11 Aug, 2024 - V3.1.0 (Release)
* 11 Sep, 2025 - V3.2.0 New WebUI with authentication
* 15 Nov, 2025 - V3.3.0 Pre release
* 22 Nov, 2025 - V3.3.1 Public release
* 20 Dec, 2025 - V3.3.2 Minimal helm chart and documentation improvements
* 20 Dec, 2025 - V3.3.3 Relicense project from GPL-3.0 to Apache-2.0
Version history and release notes live in [CHANGELOG.md](CHANGELOG.md).
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, pull request expectations, and security reporting.
---
@ -619,7 +617,9 @@ Helm OCI support enables you to pull and install Helm charts directly from conta
### 2. Prepare Namespace, Secrets, and ConfigMap
Before installing the Helm chart, create a dedicated namespace and provide the required secrets and configuration for the MaksIT.CertsUI Webapi.
By default, the chart creates the server Secret, server ConfigMap, and client ConfigMap from Helm values (`certsServerSecrets`, `certsServerConfig`, `certsClientRuntime`) as defined in [`src/helm/values.yaml`](src/helm/values.yaml). Set those keys in your `custom-values.yaml` (see the next section) and you can skip the manual `kubectl` resources below.
If you prefer to manage Secrets and ConfigMaps yourself, create them in the namespace and point the chart at them with `components.server.secretsFile.existingSecret`, `components.server.configMapFile.existingConfigMap`, and `components.client.configMapFile.existingConfigMap` (leave the templated `content` unused for that component).
**Step 1: Create Namespace**
@ -629,16 +629,18 @@ kubectl create namespace certs-ui
**Step 2: Create the Secret (`appsecrets.json`)**
Replace the placeholder values with your actual secrets. This secret contains authentication and agent keys required by the Webapi.
Replace the placeholder values with your actual secrets. This secret contains authentication and agent keys required by the Webapi (same shape as the charts templated `appsecrets.json`).
```json
{
"Auth": {
"Secret": "<your-auth-secret>",
"Pepper": "<your-pepper>"
},
"Agent": {
"AgentKey": "<your-agent-key>"
"Configuration": {
"Auth": {
"Secret": "<your-auth-secret>",
"Pepper": "<your-pepper>"
},
"Agent": {
"AgentKey": "<your-agent-key>"
}
}
}
```
@ -646,12 +648,14 @@ Replace the placeholder values with your actual secrets. This secret contains au
```bash
kubectl create secret generic certs-ui-server-secrets \
--from-literal=appsecrets.json='{
"Auth": {
"Secret": "<your-auth-secret>",
"Pepper": "<your-pepper>"
},
"Agent": {
"AgentKey": "<your-agent-key>"
"Configuration": {
"Auth": {
"Secret": "<your-auth-secret>",
"Pepper": "<your-pepper>"
},
"Agent": {
"AgentKey": "<your-agent-key>"
}
}
}' \
-n certs-ui
@ -669,10 +673,11 @@ Edit the values as needed for your environment. This configmap contains applicat
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Configuration": {
"Auth": {
"Issuer": "<your-issuer>",
@ -704,6 +709,7 @@ kubectl create configmap certs-ui-server-configmap \
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Configuration": {
"Auth": {
"Issuer": "<your-issuer>",
@ -730,91 +736,92 @@ kubectl create configmap certs-ui-server-configmap \
**Note:**
Replace all JWT-related placeholder values `<your-issuer>`, `<your-audience>` and `<your-agent-hostname>` with your environment-specific values.
**Step 4: Create the ConfigMap (`config.json`)**
**Step 4: Create the ConfigMap (`config.js`)**
Edit the values as needed for your environment. This configmap contains client settings to connect backend.
Edit the values as needed for your environment. This ConfigMap supplies the WebUI runtime config. When using Helm values instead, set `certsClientRuntime.apiUrl` (see the next section).
```bash
```javascript
window.RUNTIME_CONFIG = {
API_URL: "http://<your-server-hostname>/api"
API_URL: "http://<your-public-hostname>/api"
};
```
Use the URL your **browser** will call for the API (often the reverse proxy or ingress hostname, not an internal ClusterIP).
```bash
kubectl create configmap certs-ui-client-configmap \
--from-literal=config.js='
window.RUNTIME_CONFIG = {
API_URL: "http://<your-server-hostname>/api"
API_URL: "http://<your-public-hostname>/api"
};' \
-n certs-ui
```
**Note:**
Replace `<your-server-hostname>` with the actual hostname or IP address where your MaksIT.CertsUI server is configured.
This ConfigMap provides the client-side runtime configuration for the WebUI to connect to the backend API.
Replace `<your-public-hostname>` with the hostname or IP users use to reach the app (including TLS and port if not 80/443).
### 3. Create a Minimal Custom Values File
Below is a minimal example of a `custom-values.yaml` for most users. It sets the storage class for persistent volumes, and configures the reverse proxy service. You can further customize this file as needed for your environment.
Below is a minimal `custom-values.yaml` aligned with the charts value schema in [`src/helm/values.yaml`](src/helm/values.yaml). It sets the client API URL, storage class for server PVCs, and optional registry pull secrets.
```yaml
global:
imagePullSecrets: [] # Keep empty
imagePullSecrets: []
certsClientRuntime:
apiUrl: "https://certs-ui.example.com/api"
components:
server:
persistence:
storageClass: local-path
reverseproxy:
service:
enabled: true
type: ClusterIP
port: 8080
targetPort: 8080
```
Override **`certsServerSecrets`** and **`certsServerConfig`** here for production (JWT issuer/audience, agent hostname, ACME endpoints, and auth secrets). Chart defaults are placeholders only.
**Services:** The chart renders one `Service` per component (`server`, `client`, `reverseproxy`). Each `service` block supports `enabled`, `type`, `port`, and `targetPort` only. For Cilium LB-IPAM, MetalLB, or cloud load balancers, use a separate manifest or your platforms pattern so you can set annotations, `loadBalancerIP`, and session affinity; point that Service at the **reverseproxy** pods (`app.kubernetes.io/component: reverseproxy`).
### 4. Install the Helm Chart
Install the MaksIT.CertsUI chart using your custom values file.
Install or upgrade the MaksIT.CertsUI chart using your custom values file (`helm upgrade --install` creates the release on first run).
**On Linux:**
```bash
helm upgrade certs-ui oci://cr.maks-it.com/charts/certs-ui \
helm upgrade --install certs-ui oci://cr.maks-it.com/charts/certs-ui \
-n certs-ui \
-f custom-values.yaml \
--version 3.3.3 \
```
**On Windows PowerShell:*
```powershell
helm upgrade certs-ui oci://cr.maks-it.com/charts/certs-ui `
-n certs-ui `
-f custom-values.yaml `
--version 3.3.3 `
```
**Note:**
Chart version follows app version. To install a specific version, use the `--version` flag:
### 5. Uninstall the Helm Chart
To uninstall the MaksIT.CertsUI chart and remove all associated resources, run the following command:
**On Linux:**
```bash
helm uninstall certs-ui oci://cr.maks-it.com/charts/certs-ui \
-n certs-ui
--version X.Y.Z
```
**On Windows PowerShell:**
```powershell
helm uninstall certs-ui oci://cr.maks-it.com/charts/certs-ui `
-n certs-ui-test
helm upgrade --install certs-ui oci://cr.maks-it.com/charts/certs-ui `
-n certs-ui `
-f custom-values.yaml `
--version X.Y.Z
```
**Note:**
`Chart.yaml` in the repository uses placeholder `version` / `appVersion` (`0.0.0`); the release pipeline sets both from the app semver when pushing the chart. When installing from your registry, pass `--version` with the chart version you published (same semver as the app release, e.g. `3.3.4`).
### 5. Uninstall the Helm Chart
PVCs for the server component use `helm.sh/resource-policy: keep` by default (`components.server.persistence.volumes[].pvc.keep: true`), so **`helm uninstall` does not delete them**—ACME/cache/data volumes remain until you remove the claims manually. Set `pvc.keep: false` on a volume if you want that claim deleted with the release.
To uninstall the release (deployments, services, etc.) run:
**On Linux:**
```bash
helm uninstall certs-ui -n certs-ui
```
**On Windows PowerShell:**
```powershell
helm uninstall certs-ui -n certs-ui
```
---

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Branch Coverage: 10.6%">
<title>Branch Coverage: 10.6%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="150" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#fe7d37"/>
<rect width="150" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">10.6%</text>
<text x="128.75" y="14" fill="#fff">10.6%</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 21.7%">
<title>Line Coverage: 21.7%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="137" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="94.5" height="20" fill="#555"/>
<rect x="94.5" width="42.5" height="20" fill="#dfb317"/>
<rect width="137" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">21.7%</text>
<text x="115.75" y="14" fill="#fff">21.7%</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 29.7%">
<title>Method Coverage: 29.7%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="150" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#dfb317"/>
<rect width="150" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">29.7%</text>
<text x="128.75" y="14" fill="#fff">29.7%</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,9 +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 "%~dp0Deploy-Helm.ps1"
pause

View File

@ -1,68 +0,0 @@
# Set variables
$projectName = "certs-ui"
$namespace = "certs-ui"
$chartPath = "./helm"
$harborUrl = "cr.maks-it.com"
# Retrieve and decode username:password from environment variable (Base64)
try {
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Env:CR_MAKS_IT))
} catch {
Write-Error "Failed to decode CR_MAKS_IT as Base64. Expected base64('username:password')."
exit 1
}
# Split decoded credentials
$creds = $decoded -split ':', 2
$harborUsername = $creds[0]
$harborPassword = $creds[1]
# Verify environment variable
if (-not $harborUsername -or -not $harborPassword) {
Write-Error "Decoded CR_MAKS_IT must be in the format 'username:password'."
exit 1
}
# Ensure namespace exists
if (-not (kubectl get ns $namespace -o name 2>$null)) {
Write-Output "Creating namespace '$namespace'..."
kubectl create namespace $namespace | Out-Null
}
else {
Write-Output "Namespace '$namespace' already exists."
}
# Create or update Docker registry pull secret
Write-Output "Creating or updating image pull secret..."
kubectl -n $namespace create secret docker-registry cr-maksit-pull `
--docker-server=$harborUrl `
--docker-username=$harborUsername `
--docker-password=$harborPassword `
--docker-email="devnull@maks-it.com" `
--dry-run=client -o yaml | kubectl apply -f - | Out-Null
# Lint Helm chart
Write-Output "Linting Helm chart..."
helm lint $chartPath
# Render Helm chart to verify output (optional)
Write-Output "Rendering Helm chart for validation..."
helm template $projectName $chartPath -n $namespace | Out-Null
# Generate a unique rollout value (current Unix timestamp)
$rollme = [int][double]::Parse((Get-Date -UFormat %s))
# Deploy Helm release
Write-Output "Deploying Helm release '$projectName'..."
helm upgrade --install $projectName $chartPath -n $namespace `
--set imagePullSecret.create=false `
--set imagePullSecrets[0].name=cr-maksit-pull `
--set-string "rollme=$rollme"
# Check deployment status
Write-Output "Waiting for deployment rollout..."
kubectl -n $namespace rollout status deployment/$projectName-reverseproxy
# Display service details
Write-Output "Service information:"
kubectl -n $namespace get svc $projectName-reverseproxy

View File

@ -0,0 +1,13 @@
using MaksIT.LetsEncrypt.Entities;
using Xunit;
namespace MaksIT.LetsEncrypt.Tests;
public class ContentTypeTests
{
[Fact]
public void ContentType_defines_expected_values()
{
Assert.Equal(4, Enum.GetValues<ContentType>().Length);
}
}

View File

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>MaksIT.LetsEncrypt.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LetsEncrypt\LetsEncrypt.csproj" />
</ItemGroup>
</Project>

View File

@ -1,12 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
</PropertyGroup>
<ItemGroup>

View File

@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LetsEncrypt", "LetsEncrypt\
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Documentation", "Documentation", "{3374FDB1-C95E-4103-8E14-5BBF0BDC4E9D}"
ProjectSection(SolutionItems) = preProject
..\CHANGELOG.md = ..\CHANGELOG.md
..\CONTRIBUTING.md = ..\CONTRIBUTING.md
..\LICENSE.md = ..\LICENSE.md
..\README.md = ..\README.md
EndProjectSection
@ -19,32 +21,100 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.Models", "MaksIT.Mod
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReverseProxy", "ReverseProxy\ReverseProxy.csproj", "{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LetsEncrypt.Tests", "LetsEncrypt.Tests\LetsEncrypt.Tests.csproj", "{65DEB577-FB14-488C-AEB1-3172F0812950}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaksIT.Webapi.Tests", "MaksIT.Webapi.Tests\MaksIT.Webapi.Tests.csproj", "{EB57DB1C-DEEE-4952-9326-FB09903651F4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Debug|x64.ActiveCfg = Debug|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Debug|x64.Build.0 = Debug|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Debug|x86.ActiveCfg = Debug|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Debug|x86.Build.0 = Debug|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Release|Any CPU.Build.0 = Release|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Release|x64.ActiveCfg = Release|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Release|x64.Build.0 = Release|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Release|x86.ActiveCfg = Release|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Release|x86.Build.0 = Release|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Debug|x64.ActiveCfg = Debug|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Debug|x64.Build.0 = Debug|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Debug|x86.ActiveCfg = Debug|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Debug|x86.Build.0 = Debug|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Release|Any CPU.Build.0 = Release|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Release|x64.ActiveCfg = Release|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Release|x64.Build.0 = Release|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Release|x86.ActiveCfg = Release|Any CPU
{B5F39E04-C2E3-49BF-82C2-9DEBAA949E3D}.Release|x86.Build.0 = Release|Any CPU
{0233E43F-435D-4309-B20C-ECD4BFBD2E63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0233E43F-435D-4309-B20C-ECD4BFBD2E63}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0233E43F-435D-4309-B20C-ECD4BFBD2E63}.Debug|x64.ActiveCfg = Debug|x64
{0233E43F-435D-4309-B20C-ECD4BFBD2E63}.Debug|x86.ActiveCfg = Debug|x86
{0233E43F-435D-4309-B20C-ECD4BFBD2E63}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0233E43F-435D-4309-B20C-ECD4BFBD2E63}.Release|Any CPU.Build.0 = Release|Any CPU
{0233E43F-435D-4309-B20C-ECD4BFBD2E63}.Release|x64.ActiveCfg = Release|x64
{0233E43F-435D-4309-B20C-ECD4BFBD2E63}.Release|x86.ActiveCfg = Release|x86
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Debug|x64.ActiveCfg = Debug|Any CPU
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Debug|x64.Build.0 = Debug|Any CPU
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Debug|x86.ActiveCfg = Debug|Any CPU
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Debug|x86.Build.0 = Debug|Any CPU
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Release|Any CPU.Build.0 = Release|Any CPU
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Release|x64.ActiveCfg = Release|Any CPU
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Release|x64.Build.0 = Release|Any CPU
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Release|x86.ActiveCfg = Release|Any CPU
{6814169B-D4D0-40B2-9FA9-89997DD44C30}.Release|x86.Build.0 = Release|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Debug|x64.ActiveCfg = Debug|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Debug|x64.Build.0 = Debug|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Debug|x86.ActiveCfg = Debug|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Debug|x86.Build.0 = Debug|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Release|Any CPU.Build.0 = Release|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Release|x64.ActiveCfg = Release|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Release|x64.Build.0 = Release|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Release|x86.ActiveCfg = Release|Any CPU
{BE051147-7AB7-4358-9C24-5CB40FAFF4FC}.Release|x86.Build.0 = Release|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Debug|Any CPU.Build.0 = Debug|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Debug|x64.ActiveCfg = Debug|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Debug|x64.Build.0 = Debug|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Debug|x86.ActiveCfg = Debug|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Debug|x86.Build.0 = Debug|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Release|Any CPU.ActiveCfg = Release|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Release|Any CPU.Build.0 = Release|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Release|x64.ActiveCfg = Release|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Release|x64.Build.0 = Release|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Release|x86.ActiveCfg = Release|Any CPU
{65DEB577-FB14-488C-AEB1-3172F0812950}.Release|x86.Build.0 = Release|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Debug|x64.ActiveCfg = Debug|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Debug|x64.Build.0 = Debug|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Debug|x86.ActiveCfg = Debug|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Debug|x86.Build.0 = Debug|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Release|Any CPU.Build.0 = Release|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Release|x64.ActiveCfg = Release|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Release|x64.Build.0 = Release|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Release|x86.ActiveCfg = Release|Any CPU
{EB57DB1C-DEEE-4952-9326-FB09903651F4}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -1,25 +0,0 @@
npm create vite@latest my-react-app
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -19,7 +19,6 @@
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-pdf": "^10.2.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.6",
"react-virtualized": "^9.22.6",
@ -999,191 +998,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.81.tgz",
"integrity": "sha512-ReCjd5SYI/UKx/olaQLC4GtN6wUQGjlgHXs1lvUvWGXfBMR3Fxnik3cL+OxKN5ithNdoU0/GlCrdKcQDFh2XKQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.81",
"@napi-rs/canvas-darwin-arm64": "0.1.81",
"@napi-rs/canvas-darwin-x64": "0.1.81",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.81",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.81",
"@napi-rs/canvas-linux-arm64-musl": "0.1.81",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.81",
"@napi-rs/canvas-linux-x64-gnu": "0.1.81",
"@napi-rs/canvas-linux-x64-musl": "0.1.81",
"@napi-rs/canvas-win32-x64-msvc": "0.1.81"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.81.tgz",
"integrity": "sha512-78Lz+AUi+MsWupyZjXwpwQrp1QCwncPvRZrdvrROcZ9Gq9grP7LfQZiGdR8LKyHIq3OR18mDP+JESGT15V1nXw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.81.tgz",
"integrity": "sha512-omejuKgHWKDGoh8rsgsyhm/whwxMaryTQjJTd9zD7hiB9/rzcEEJLHnzXWR5ysy4/tTjHaQotE6k2t8eodTLnA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.81.tgz",
"integrity": "sha512-EYfk+co6BElq5DXNH9PBLYDYwc4QsvIVbyrsVHsxVpn4p6Y3/s8MChgC69AGqj3vzZBQ1qx2CRCMtg5cub+XuQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.81.tgz",
"integrity": "sha512-teh6Q74CyAcH31yLNQGR9MtXSFxlZa5CI6vvNUISI14gWIJWrhOwUAOly+KRe1aztWR0FWTVSPxM4p5y+06aow==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.81.tgz",
"integrity": "sha512-AGEopHFYRzJOjxY+2G1RmHPRnuWvO3Qdhq7sIazlSjxb3Z6dZHg7OB/4ZimXaimPjDACm9qWa6t5bn9bhXvkcw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.81.tgz",
"integrity": "sha512-Bj3m1cl4GIhsigkdwOxii4g4Ump3/QhNpx85IgAlCCYXpaly6mcsWpuDYEabfIGWOWhDUNBOndaQUPfWK1czOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.81.tgz",
"integrity": "sha512-yg/5NkHykVdwPlD3XObwCa/EswkOwLHswJcI9rHrac+znHsmCSj5AMX/RTU9Z9F6lZTwL60JM2Esit33XhAMiw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.81.tgz",
"integrity": "sha512-tPfMpSEBuV5dJSKexO/UZxpOqnYTaNbG8aKa1ek8QsWu+4SJ/foWkaxscra/RUv85vepx6WWDjzBNbNJsTnO0w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.81.tgz",
"integrity": "sha512-1L0xnYgzqn8Baef+inPvY4dKqdmw3KCBoe0NEDgezuBZN7MA5xElwifoG8609uNdrMtJ9J6QZarsslLRVqri7g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.81.tgz",
"integrity": "sha512-57ryVbhm/z7RE9/UVcS7mrLPdlayLesy+9U0Uf6epCoeSGrs99tfieCcgZWFbIgmByQ1AZnNtFI2N6huqDLlWQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -3181,15 +2995,6 @@
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@ -5112,24 +4917,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-cancellable-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
}
},
"node_modules/make-event-props": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -5139,23 +4926,6 @@
"node": ">= 0.4"
}
},
"node_modules/merge-refs": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -5479,18 +5249,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -5666,44 +5424,6 @@
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
"node_modules/react-pdf": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.2.0.tgz",
"integrity": "sha512-zk0DIL31oCh8cuQycM0SJKfwh4Onz0/Nwi6wTOjgtEjWGUY6eM+/vuzvOP3j70qtEULn7m1JtaeGzud1w5fY2Q==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"dequal": "^2.0.3",
"make-cancellable-promise": "^2.0.0",
"make-event-props": "^2.0.0",
"merge-refs": "^2.0.0",
"pdfjs-dist": "5.4.296",
"tiny-invariant": "^1.0.0",
"warning": "^4.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-pdf/node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
@ -6349,12 +6069,6 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -6757,15 +6471,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -21,7 +21,6 @@
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-pdf": "^10.2.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.6",
"react-virtualized": "^9.22.6",

View File

@ -2,14 +2,18 @@ import { FC, ReactNode } from 'react'
interface FormContentProps {
children?: ReactNode
/** Merged after base layout; use e.g. `flex flex-col overflow-hidden` when a child should fill height (iframe). */
className?: string
}
const FormContent: FC<FormContentProps> = (props) => {
const {
children
children,
className
} = props
return <div className={'bg-gray-100 w-full h-full p-4 overflow-y-auto'}>
const base = 'bg-gray-100 w-full h-full min-h-0 p-4'
return <div className={className ? `${base} ${className}` : `${base} overflow-y-auto`}>
{children}
</div>
}

View File

@ -1,43 +1,18 @@
import { FC, useEffect, useRef, useState } from 'react'
import { FC, useEffect, useState } from 'react'
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
import { ApiRoutes, GetApiRoute } from '../AppMap'
import { getData, postData } from '../axiosConfig'
import { pdfjs, Document, Page } from 'react-pdf'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
import type { PDFDocumentProxy } from 'pdfjs-dist'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
// pdfjs-dist worker (bundled asset URL for prod)
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
/**
* Uses the browser's native PDF viewer (iframe + blob URL). No PDF.js / workers / extra MIME rules.
* UX varies slightly by browser; mobile Safari may open PDF in a new context instead of inline.
*/
const LetsEncryptTermsOfService: FC = () => {
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const [objectUrl, setObjectUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [numPages, setNumPages] = useState<number>()
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState<number>()
useEffect(() => {
const handleResize = () => {
if (containerRef.current) {
const { x } = containerRef.current.getBoundingClientRect()
const width = window.innerWidth - x
setContainerWidth(width)
}
}
handleResize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
useEffect(() => {
setLoading(true)
@ -46,10 +21,13 @@ const LetsEncryptTermsOfService: FC = () => {
})
.then(response => {
if (!response) return
return getData<string>(GetApiRoute(ApiRoutes.CERTS_FLOW_TERMS_OF_SERVICE).route.replace('{sessionId}', response))
return getData<string>(
GetApiRoute(ApiRoutes.CERTS_FLOW_TERMS_OF_SERVICE).route.replace('{sessionId}', response),
120_000
)
})
.then(base64Pdf => {
if (base64Pdf) {
if (typeof base64Pdf === 'string' && base64Pdf.length > 0) {
setPdfUrl(base64Pdf)
} else {
setError('Failed to retrieve PDF.')
@ -59,12 +37,16 @@ const LetsEncryptTermsOfService: FC = () => {
.finally(() => setLoading(false))
}, [])
// Convert base64 to Blob and create object URL
useEffect(() => {
if (!pdfUrl) return
// Remove data URL prefix if present
const base64 = pdfUrl.replace(/^data:application\/pdf;base64,/, '')
const byteCharacters = atob(base64)
const base64 = pdfUrl.replace(/^data:application\/pdf;base64,/, '').replace(/\s/g, '')
let byteCharacters: string
try {
byteCharacters = atob(base64)
} catch {
setError('Invalid PDF data from server.')
return
}
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
@ -78,42 +60,28 @@ const LetsEncryptTermsOfService: FC = () => {
}
}, [pdfUrl])
const handleDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages)
}
return (
<FormContainer>
<FormHeader>Let's Encrypt Terms of Service</FormHeader>
<FormContent>
{loading && <div>Loading Terms of Service...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
{objectUrl && (
<div ref={containerRef} className={'w-full overflow-auto'} style={{ minHeight: 600 }}>
<Document file={objectUrl} onLoadSuccess={handleDocumentLoadSuccess}>
{numPages ? (
Array.from(new Array(numPages), (_, index) => (
<div key={`page_container_${index + 1}`} className={'page-container'}>
<Page
key={`page_${index + 1}`}
pageNumber={index + 1}
width={containerWidth && containerWidth > 0 ? containerWidth : 600}
/>
<div className={'page-number w-full text-center text-sm text-gray-500'}>
Page {index + 1} / {numPages}
</div>
</div>
))
) : (
<div>Loading PDF pages...</div>
)}
</Document>
</div>
)}
<FormContent className={'flex flex-col overflow-hidden'}>
<div className={'flex min-h-0 flex-1 flex-col gap-2'}>
{loading && <div className={'shrink-0'}>Loading Terms of Service...</div>}
{error && <div className={'shrink-0 text-red-600'}>{error}</div>}
{objectUrl && !error && (
<iframe
title={"Let's Encrypt Terms of Service PDF"}
src={objectUrl}
className={
'min-h-0 w-full flex-1 rounded border border-gray-200 bg-gray-50'
}
style={{ borderWidth: 1 }}
/>
)}
</div>
</FormContent>
<FormFooter />
</FormContainer>
)
}
export { LetsEncryptTermsOfService }
export { LetsEncryptTermsOfService }

View File

@ -0,0 +1,42 @@
using MaksIT.Webapi.Domain;
using Xunit;
namespace MaksIT.Webapi.Tests.Domain;
public class SettingsDomainTests
{
[Fact]
public void Initialize_creates_admin_user()
{
var pepper = "pepper";
var result = new Settings().Initialize(pepper);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.True(result.Value!.Init);
Assert.Single(result.Value.Users);
Assert.Equal("admin", result.Value.Users[0].Name);
}
[Fact]
public void GetUserByName_returns_user_after_initialize()
{
var settings = new Settings().Initialize("p").Value!;
var found = settings.GetUserByName("admin");
Assert.True(found.IsSuccess);
Assert.Equal("admin", found.Value!.Name);
}
[Fact]
public void GetUserByName_when_missing_returns_not_found()
{
var settings = new Settings();
var found = settings.GetUserByName("nope");
Assert.False(found.IsSuccess);
}
}

View File

@ -0,0 +1,70 @@
using MaksIT.Webapi;
using Microsoft.Extensions.Options;
namespace MaksIT.Webapi.Tests.Infrastructure;
/// <summary>
/// Creates a disposable temp workspace and <see cref="IOptions{Configuration}"/> with valid auth and paths.
/// </summary>
public sealed class WebApiTestFixture : IDisposable
{
public string Root { get; }
public string SettingsFilePath { get; }
public string CacheFolderPath { get; }
public IOptions<Configuration> AppOptions { get; }
public WebApiTestFixture()
{
Root = Path.Combine(Path.GetTempPath(), "maksit-webapi-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Root);
SettingsFilePath = Path.Combine(Root, "settings.json");
CacheFolderPath = Path.Combine(Root, "cache");
Directory.CreateDirectory(CacheFolderPath);
var dataFolder = Path.Combine(Root, "data");
Directory.CreateDirectory(dataFolder);
var acmeFolder = Path.Combine(Root, "acme");
Directory.CreateDirectory(acmeFolder);
var configuration = new Configuration
{
Auth = new Auth
{
Secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
Issuer = "tests",
Audience = "tests",
Expiration = 60,
RefreshExpiration = 7,
Pepper = "test-pepper-value-for-unit-tests"
},
SettingsFile = SettingsFilePath,
Production = "https://acme-v02.api.letsencrypt.org/directory",
Staging = "https://acme-staging-v02.api.letsencrypt.org/directory",
CacheFolder = CacheFolderPath,
AcmeFolder = acmeFolder,
DataFolder = dataFolder,
Agent = new Agent
{
AgentHostname = "http://127.0.0.1",
AgentPort = 9,
AgentKey = "test-key",
ServiceToReload = "nginx"
}
};
AppOptions = Microsoft.Extensions.Options.Options.Create(configuration);
}
public void Dispose()
{
try
{
if (Directory.Exists(Root))
Directory.Delete(Root, recursive: true);
}
catch
{
// best-effort cleanup of temp dir
}
}
}

View File

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>MaksIT.Webapi.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MaksIT.Webapi\MaksIT.Webapi.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,134 @@
using MaksIT.Core.Webapi.Models;
using MaksIT.LetsEncrypt.Entities;
using MaksIT.Models.LetsEncryptServer.Account.Requests;
using MaksIT.Results;
using MaksIT.Webapi.Services;
using MaksIT.Webapi.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace MaksIT.Webapi.Tests.Services;
public class AccountServiceTests
{
private static AccountService CreateSut(WebApiTestFixture fx, ICacheService cache, ICertsFlowService flow) =>
new(NullLogger<CacheService>.Instance, fx.AppOptions, cache, flow);
[Fact]
public async Task GetAccountsAsync_WhenCacheEmpty_ReturnsEmptyArray()
{
using var fx = new WebApiTestFixture();
var cache = new Mock<ICacheService>();
cache
.Setup(c => c.LoadAccountsFromCacheAsync())
.ReturnsAsync(Result<RegistrationCache[]?>.Ok(Array.Empty<RegistrationCache>()));
var flow = new Mock<ICertsFlowService>();
var sut = CreateSut(fx, cache.Object, flow.Object);
var result = await sut.GetAccountsAsync();
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.Empty(result.Value);
cache.Verify(c => c.LoadAccountsFromCacheAsync(), Times.Once);
flow.VerifyNoOtherCalls();
}
[Fact]
public async Task GetAccountAsync_WhenPresent_ReturnsMappedResponse()
{
using var fx = new WebApiTestFixture();
var accountId = Guid.NewGuid();
var reg = new RegistrationCache
{
AccountId = accountId,
Description = "acc",
Contacts = ["mailto:a@b"],
IsStaging = false,
ChallengeType = "http-01",
IsDisabled = false
};
var cache = new Mock<ICacheService>();
cache.Setup(c => c.LoadAccountFromCacheAsync(accountId))
.ReturnsAsync(Result<RegistrationCache?>.Ok(reg));
var flow = new Mock<ICertsFlowService>();
var sut = CreateSut(fx, cache.Object, flow.Object);
var result = await sut.GetAccountAsync(accountId);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.Equal(accountId, result.Value!.AccountId);
Assert.Equal("acc", result.Value.Description);
flow.VerifyNoOtherCalls();
}
[Fact]
public async Task PatchAccountAsync_SetDescription_persists_and_returns_updated()
{
using var fx = new WebApiTestFixture();
var accountId = Guid.NewGuid();
var reg = new RegistrationCache
{
AccountId = accountId,
Description = "old",
Contacts = ["mailto:a@b"],
IsStaging = false,
ChallengeType = "http-01",
IsDisabled = false
};
using var cacheSvc = new CacheService(NullLogger<CacheService>.Instance, fx.AppOptions);
await cacheSvc.SaveToCacheAsync(accountId, reg);
var cacheMock = new Mock<ICacheService>();
cacheMock
.Setup(c => c.LoadAccountFromCacheAsync(accountId))
.Returns(() => cacheSvc.LoadAccountFromCacheAsync(accountId));
cacheMock
.Setup(c => c.SaveToCacheAsync(accountId, It.IsAny<RegistrationCache>()))
.Returns<Guid, RegistrationCache>((_, c) => cacheSvc.SaveToCacheAsync(accountId, c));
var flow = new Mock<ICertsFlowService>();
var sut = CreateSut(fx, cacheMock.Object, flow.Object);
var patch = new PatchAccountRequest
{
Description = "new-desc",
Operations = new Dictionary<string, PatchOperation>
{
[nameof(PatchAccountRequest.Description)] = PatchOperation.SetField
}
};
var result = await sut.PatchAccountAsync(accountId, patch);
Assert.True(result.IsSuccess);
Assert.Equal("new-desc", result.Value!.Description);
var reload = await cacheSvc.LoadAccountFromCacheAsync(accountId);
Assert.True(reload.IsSuccess);
Assert.Equal("new-desc", reload.Value!.Description);
flow.VerifyNoOtherCalls();
}
[Fact]
public async Task DeleteAccountAsync_calls_DeleteAccountCacheAsync()
{
using var fx = new WebApiTestFixture();
var id = Guid.NewGuid();
var cache = new Mock<ICacheService>();
cache.Setup(c => c.DeleteAccountCacheAsync(id)).ReturnsAsync(Result.Ok());
var flow = new Mock<ICertsFlowService>();
var sut = CreateSut(fx, cache.Object, flow.Object);
var result = await sut.DeleteAccountAsync(id);
Assert.True(result.IsSuccess);
cache.Verify(c => c.DeleteAccountCacheAsync(id), Times.Once);
flow.VerifyNoOtherCalls();
}
}

View File

@ -0,0 +1,45 @@
using System.Net;
using MaksIT.Webapi.Services;
using MaksIT.Webapi.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace MaksIT.Webapi.Tests.Services;
public class AgentServiceTests
{
private sealed class OkHandler : HttpMessageHandler
{
private readonly string _body;
public OkHandler(string body) => _body = body;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Assert.Equal(HttpMethod.Get, request.Method);
Assert.Contains("HelloWorld", request.RequestUri!.ToString(), StringComparison.Ordinal);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(_body)
});
}
}
[Fact]
public async Task GetHelloWorld_OnSuccess_ReturnsMessageFromBody()
{
using var fx = new WebApiTestFixture();
var client = new HttpClient(new OkHandler("hello-from-agent"))
{
Timeout = TimeSpan.FromSeconds(5)
};
var sut = new AgentService(fx.AppOptions, NullLogger<AgentService>.Instance, client);
var result = await sut.GetHelloWorld();
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.Equal("hello-from-agent", result.Value.Message);
}
}

View File

@ -0,0 +1,104 @@
using MaksIT.LetsEncrypt.Entities;
using MaksIT.Webapi.Services;
using MaksIT.Webapi.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace MaksIT.Webapi.Tests.Services;
public class CacheServiceTests
{
[Fact]
public async Task LoadAccountsFromCacheAsync_WhenNoJsonFiles_ReturnsEmptyArray()
{
using var fx = new WebApiTestFixture();
using var sut = new CacheService(NullLogger<CacheService>.Instance, fx.AppOptions);
var result = await sut.LoadAccountsFromCacheAsync();
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.Empty(result.Value);
}
[Fact]
public async Task SaveToCacheAsync_LoadAccountFromCacheAsync_DeleteAccountCacheAsync_roundtrip()
{
using var fx = new WebApiTestFixture();
using var sut = new CacheService(NullLogger<CacheService>.Instance, fx.AppOptions);
var accountId = Guid.NewGuid();
var cache = new RegistrationCache
{
AccountId = accountId,
Description = "unit-test",
Contacts = ["mailto:test@example.com"],
IsStaging = true,
ChallengeType = "http-01",
IsDisabled = false
};
var save = await sut.SaveToCacheAsync(accountId, cache);
Assert.True(save.IsSuccess);
var load = await sut.LoadAccountFromCacheAsync(accountId);
Assert.True(load.IsSuccess);
Assert.NotNull(load.Value);
Assert.Equal(accountId, load.Value.AccountId);
Assert.Equal("unit-test", load.Value.Description);
var del = await sut.DeleteAccountCacheAsync(accountId);
Assert.True(del.IsSuccess);
var loadAfter = await sut.LoadAccountFromCacheAsync(accountId);
Assert.False(loadAfter.IsSuccess);
}
[Fact]
public async Task LoadAccountFromCacheAsync_WhenFileMissing_ReturnsError()
{
using var fx = new WebApiTestFixture();
using var sut = new CacheService(NullLogger<CacheService>.Instance, fx.AppOptions);
var result = await sut.LoadAccountFromCacheAsync(Guid.NewGuid());
Assert.False(result.IsSuccess);
}
[Fact]
public async Task LoadAccountFromCacheAsync_WhenFileEmpty_ReturnsError()
{
using var fx = new WebApiTestFixture();
using var sut = new CacheService(NullLogger<CacheService>.Instance, fx.AppOptions);
var id = Guid.NewGuid();
await File.WriteAllTextAsync(Path.Combine(fx.CacheFolderPath, $"{id}.json"), "");
var result = await sut.LoadAccountFromCacheAsync(id);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task DeleteCacheAsync_RemovesFilesInCacheFolder()
{
using var fx = new WebApiTestFixture();
using var sut = new CacheService(NullLogger<CacheService>.Instance, fx.AppOptions);
await File.WriteAllTextAsync(Path.Combine(fx.CacheFolderPath, "extra.txt"), "x");
var result = await sut.DeleteCacheAsync();
Assert.True(result.IsSuccess);
Assert.Empty(Directory.GetFiles(fx.CacheFolderPath));
}
[Fact]
public async Task DeleteAccountCacheAsync_WhenFileMissing_still_ok()
{
using var fx = new WebApiTestFixture();
using var sut = new CacheService(NullLogger<CacheService>.Instance, fx.AppOptions);
var result = await sut.DeleteAccountCacheAsync(Guid.NewGuid());
Assert.True(result.IsSuccess);
}
}

View File

@ -0,0 +1,364 @@
using System.Net;
using MaksIT.LetsEncrypt.Entities;
using MaksIT.LetsEncrypt.Entities.LetsEncrypt;
using MaksIT.LetsEncrypt.Services;
using MaksIT.Results;
using MaksIT.Webapi.Services;
using MaksIT.Webapi.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace MaksIT.Webapi.Tests.Services;
public sealed class CertsFlowServiceTests
{
private static CertsFlowService CreateSut(
WebApiTestFixture fx,
Mock<ILetsEncryptService> le,
Mock<ICacheService>? cache = null,
Mock<IAgentService>? agent = null,
HttpMessageHandler? httpHandler = null)
{
cache ??= new Mock<ICacheService>();
agent ??= new Mock<IAgentService>();
var handler = httpHandler ?? new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([0x25, 0x50, 0x44, 0x46]) });
var httpClient = new HttpClient(handler, disposeHandler: true);
return new CertsFlowService(
fx.AppOptions,
NullLogger<CertsFlowService>.Instance,
httpClient,
le.Object,
cache.Object,
agent.Object);
}
[Fact]
public async Task ConfigureClientAsync_WhenConfigureSucceeds_ReturnsNewSessionId()
{
using var fx = new WebApiTestFixture();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.ConfigureClient(It.IsAny<Guid>(), false))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le);
var result = await sut.ConfigureClientAsync(isStaging: false);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
}
[Fact]
public async Task ConfigureClientAsync_WhenConfigureFails_PropagatesFailure()
{
using var fx = new WebApiTestFixture();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.ConfigureClient(It.IsAny<Guid>(), It.IsAny<bool>()))
.ReturnsAsync(Result.InternalServerError(["configure failed"]));
var sut = CreateSut(fx, le);
var result = await sut.ConfigureClientAsync(isStaging: true);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task InitAsync_WhenAccountIdNull_CallsInitWithNewAccountId()
{
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.Is<string[]>(c => c.Length == 1 && c[0] == "mailto:a@b"), null))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le);
var result = await sut.InitAsync(sessionId, null, "d", ["mailto:a@b"]);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
le.Verify(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.IsAny<string[]>(), null), Times.Once);
}
[Fact]
public async Task InitAsync_WhenCacheMiss_GeneratesNewAccountId()
{
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var requestedAccount = Guid.NewGuid();
var cache = new Mock<ICacheService>();
cache.Setup(c => c.LoadAccountFromCacheAsync(requestedAccount))
.ReturnsAsync(Result<RegistrationCache?>.InternalServerError(null, "missing"));
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.Init(sessionId, It.IsAny<Guid>(), "d", It.IsAny<string[]>(), null))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le, cache);
var result = await sut.InitAsync(sessionId, requestedAccount, "d", ["mailto:a@b"]);
Assert.True(result.IsSuccess);
Assert.NotEqual(requestedAccount, result.Value);
}
[Fact]
public async Task InitAsync_WhenCacheHit_PassesCacheToInit()
{
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var accountId = Guid.NewGuid();
var reg = new RegistrationCache
{
AccountId = accountId,
Description = "x",
Contacts = ["mailto:x@y"],
IsStaging = false,
ChallengeType = "http-01"
};
var cache = new Mock<ICacheService>();
cache.Setup(c => c.LoadAccountFromCacheAsync(accountId))
.ReturnsAsync(Result<RegistrationCache?>.Ok(reg));
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.Init(sessionId, accountId, "d", It.IsAny<string[]>(), reg))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le, cache);
var result = await sut.InitAsync(sessionId, accountId, "d", ["mailto:a@b"]);
Assert.True(result.IsSuccess);
Assert.Equal(accountId, result.Value);
}
[Fact]
public async Task NewOrderAsync_WhenOrderSucceeds_WritesAcmeTokenFiles()
{
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.NewOrder(sessionId, It.IsAny<string[]>(), "http-01"))
.ReturnsAsync(Result<Dictionary<string, string>?>.Ok(new Dictionary<string, string>
{
["example.com"] = "tokenPart.rest.of.token"
}));
var sut = CreateSut(fx, le);
var result = await sut.NewOrderAsync(sessionId, ["example.com"], "http-01");
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.Contains("tokenPart", result.Value);
var path = Path.Combine(fx.AppOptions.Value.AcmeFolder, "tokenPart");
Assert.True(File.Exists(path));
Assert.Equal("tokenPart.rest.of.token", await File.ReadAllTextAsync(path));
}
[Fact]
public void GetTermsOfService_WhenLetsEncryptFails_Propagates()
{
using var fx = new WebApiTestFixture();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.GetTermsOfServiceUri(It.IsAny<Guid>()))
.Returns(Result<string?>.InternalServerError(null, "no uri"));
var sut = CreateSut(fx, le);
var result = sut.GetTermsOfService(Guid.NewGuid());
Assert.False(result.IsSuccess);
}
[Fact]
public void GetTermsOfService_WhenPdfAlreadyOnDisk_ReturnsBase64WithoutHttp()
{
using var fx = new WebApiTestFixture();
var fileName = "cached-tos.pdf";
var dataPath = Path.Combine(fx.AppOptions.Value.DataFolder, fileName);
File.WriteAllBytes(dataPath, [7, 7, 7]);
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.GetTermsOfServiceUri(It.IsAny<Guid>()))
.Returns(Result<string?>.Ok($"https://acme.test/sub/{fileName}"));
var httpHit = false;
var sut = CreateSut(fx, le, httpHandler: new StubHttpMessageHandler(_ =>
{
httpHit = true;
return new HttpResponseMessage(HttpStatusCode.OK);
}));
var result = sut.GetTermsOfService(Guid.NewGuid());
Assert.True(result.IsSuccess);
Assert.Equal(Convert.ToBase64String([7, 7, 7]), result.Value);
Assert.False(httpHit);
}
[Fact]
public void AcmeChallenge_WhenFileMissing_ReturnsNotFound()
{
using var fx = new WebApiTestFixture();
var le = new Mock<ILetsEncryptService>();
var sut = CreateSut(fx, le);
var result = sut.AcmeChallenge("missing-challenge");
Assert.False(result.IsSuccess);
}
[Fact]
public void AcmeChallenge_WhenFileExists_ReturnsContent()
{
using var fx = new WebApiTestFixture();
var name = "challenge-token";
File.WriteAllText(Path.Combine(fx.AppOptions.Value.AcmeFolder, name), "challenge-body");
var le = new Mock<ILetsEncryptService>();
var sut = CreateSut(fx, le);
var result = sut.AcmeChallenge(name);
Assert.True(result.IsSuccess);
Assert.Equal("challenge-body", result.Value);
}
[Fact]
public async Task ApplyCertificatesAsync_WhenAccountDisabled_ReturnsBadRequest()
{
using var fx = new WebApiTestFixture();
var accountId = Guid.NewGuid();
var reg = new RegistrationCache
{
AccountId = accountId,
Description = "d",
Contacts = [],
IsStaging = false,
ChallengeType = "http-01",
IsDisabled = true,
CachedCerts = new Dictionary<string, CertificateCache>
{
["h"] = new CertificateCache { Cert = "x", Private = null, PrivatePem = "y" }
}
};
var cache = new Mock<ICacheService>();
cache.Setup(c => c.LoadAccountFromCacheAsync(accountId))
.ReturnsAsync(Result<RegistrationCache?>.Ok(reg));
var le = new Mock<ILetsEncryptService>();
var sut = CreateSut(fx, le, cache);
var result = await sut.ApplyCertificatesAsync(accountId);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task ApplyCertificatesAsync_WhenStaging_ReturnsUnprocessable()
{
using var fx = new WebApiTestFixture();
var accountId = Guid.NewGuid();
var reg = new RegistrationCache
{
AccountId = accountId,
Description = "d",
Contacts = [],
IsStaging = true,
ChallengeType = "http-01",
IsDisabled = false,
CachedCerts = new Dictionary<string, CertificateCache>
{
["h"] = new CertificateCache { Cert = "x", Private = null, PrivatePem = "y" }
}
};
var cache = new Mock<ICacheService>();
cache.Setup(c => c.LoadAccountFromCacheAsync(accountId))
.ReturnsAsync(Result<RegistrationCache?>.Ok(reg));
var le = new Mock<ILetsEncryptService>();
var sut = CreateSut(fx, le, cache);
var result = await sut.ApplyCertificatesAsync(accountId);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task ApplyCertificatesAsync_WhenProduction_CallsAgentUploadAndReload()
{
using var fx = new WebApiTestFixture();
var accountId = Guid.NewGuid();
var reg = new RegistrationCache
{
AccountId = accountId,
Description = "d",
Contacts = [],
IsStaging = false,
ChallengeType = "http-01",
IsDisabled = false,
CachedCerts = new Dictionary<string, CertificateCache>
{
["h"] = new CertificateCache { Cert = "CERT", Private = null, PrivatePem = "PEM" }
}
};
var cache = new Mock<ICacheService>();
cache.Setup(c => c.LoadAccountFromCacheAsync(accountId))
.ReturnsAsync(Result<RegistrationCache?>.Ok(reg));
var agent = new Mock<IAgentService>();
agent.Setup(a => a.UploadCerts(It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync(Result.Ok());
agent.Setup(a => a.ReloadService(fx.AppOptions.Value.Agent.ServiceToReload))
.ReturnsAsync(Result.Ok());
var le = new Mock<ILetsEncryptService>();
var sut = CreateSut(fx, le, cache, agent);
var result = await sut.ApplyCertificatesAsync(accountId);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
agent.Verify(a => a.UploadCerts(It.IsAny<Dictionary<string, string>>()), Times.Once);
agent.Verify(a => a.ReloadService(fx.AppOptions.Value.Agent.ServiceToReload), Times.Once);
}
[Fact]
public async Task CompleteChallengesAsync_DelegatesToLetsEncrypt()
{
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.CompleteChallenges(sessionId))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le);
var result = await sut.CompleteChallengesAsync(sessionId);
Assert.True(result.IsSuccess);
le.Verify(x => x.CompleteChallenges(sessionId), Times.Once);
}
[Fact]
public async Task GetOrderAsync_DelegatesToLetsEncrypt()
{
using var fx = new WebApiTestFixture();
var sessionId = Guid.NewGuid();
var le = new Mock<ILetsEncryptService>();
le.Setup(x => x.GetOrder(sessionId, It.IsAny<string[]>()))
.ReturnsAsync(Result.Ok());
var sut = CreateSut(fx, le);
var result = await sut.GetOrderAsync(sessionId, ["a.com"]);
Assert.True(result.IsSuccess);
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _factory;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> factory) => _factory = factory;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(_factory(request));
}
}

View File

@ -0,0 +1,137 @@
using MaksIT.Core.Webapi.Models;
using MaksIT.Models.LetsEncryptServer.Identity.Login;
using MaksIT.Models.LetsEncryptServer.Identity.Logout;
using MaksIT.Models.LetsEncryptServer.Identity.User;
using MaksIT.Webapi.Authorization;
using MaksIT.Webapi.Domain;
using MaksIT.Webapi.Services;
using MaksIT.Webapi.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace MaksIT.Webapi.Tests.Services;
public class IdentityServiceTests
{
[Fact]
public async Task LoginAsync_WhenUserMissing_ReturnsNotFound()
{
using var fx = new WebApiTestFixture();
var settingsService = new SettingsService(NullLogger<SettingsService>.Instance, fx.AppOptions);
await settingsService.SaveAsync(new Settings());
var sut = new IdentityService(NullLogger<IdentityService>.Instance, fx.AppOptions, settingsService);
var result = await sut.LoginAsync(new LoginRequest { Username = "nobody", Password = "x" });
Assert.False(result.IsSuccess);
}
[Fact]
public async Task LoginAsync_WithValidAdminCredentials_ReturnsTokens()
{
using var fx = new WebApiTestFixture();
var settingsService = new SettingsService(NullLogger<SettingsService>.Instance, fx.AppOptions);
var init = new Settings().Initialize(fx.AppOptions.Value.Auth.Pepper);
Assert.True(init.IsSuccess);
await settingsService.SaveAsync(init.Value!);
var sut = new IdentityService(NullLogger<IdentityService>.Instance, fx.AppOptions, settingsService);
var result = await sut.LoginAsync(new LoginRequest { Username = "admin", Password = "password" });
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.False(string.IsNullOrEmpty(result.Value.Token));
Assert.False(string.IsNullOrEmpty(result.Value.RefreshToken));
}
[Fact]
public async Task RefreshTokenAsync_WhenTokenInvalid_ReturnsUnauthorized()
{
using var fx = new WebApiTestFixture();
var settingsService = new SettingsService(NullLogger<SettingsService>.Instance, fx.AppOptions);
var init = new Settings().Initialize(fx.AppOptions.Value.Auth.Pepper);
await settingsService.SaveAsync(init.Value!);
var sut = new IdentityService(NullLogger<IdentityService>.Instance, fx.AppOptions, settingsService);
var result = await sut.RefreshTokenAsync(new RefreshTokenRequest { RefreshToken = "not-a-real-token" });
Assert.False(result.IsSuccess);
}
[Fact]
public async Task RefreshTokenAsync_WhenTokenValid_ReturnsSameAccessUntilExpiry()
{
using var fx = new WebApiTestFixture();
var settingsService = new SettingsService(NullLogger<SettingsService>.Instance, fx.AppOptions);
var init = new Settings().Initialize(fx.AppOptions.Value.Auth.Pepper);
await settingsService.SaveAsync(init.Value!);
var sut = new IdentityService(NullLogger<IdentityService>.Instance, fx.AppOptions, settingsService);
var login = await sut.LoginAsync(new LoginRequest { Username = "admin", Password = "password" });
Assert.True(login.IsSuccess);
var refresh = await sut.RefreshTokenAsync(new RefreshTokenRequest { RefreshToken = login.Value!.RefreshToken });
Assert.True(refresh.IsSuccess);
Assert.Equal(login.Value.Token, refresh.Value!.Token);
}
[Fact]
public async Task Logout_removes_matching_access_token()
{
using var fx = new WebApiTestFixture();
var settingsService = new SettingsService(NullLogger<SettingsService>.Instance, fx.AppOptions);
var init = new Settings().Initialize(fx.AppOptions.Value.Auth.Pepper);
await settingsService.SaveAsync(init.Value!);
var sut = new IdentityService(NullLogger<IdentityService>.Instance, fx.AppOptions, settingsService);
var login = await sut.LoginAsync(new LoginRequest { Username = "admin", Password = "password" });
Assert.True(login.IsSuccess);
await sut.Logout(new LogoutRequest { Token = login.Value!.Token, LogoutFromAllDevices = false });
var second = await sut.LoginAsync(new LoginRequest { Username = "admin", Password = "password" });
Assert.True(second.IsSuccess);
Assert.NotEqual(login.Value.Token, second.Value!.Token);
}
[Fact]
public async Task PatchUserAsync_SetPassword_allows_login_with_new_password()
{
using var fx = new WebApiTestFixture();
var settingsService = new SettingsService(NullLogger<SettingsService>.Instance, fx.AppOptions);
var init = new Settings().Initialize(fx.AppOptions.Value.Auth.Pepper);
await settingsService.SaveAsync(init.Value!);
var admin = init.Value!.Users[0];
var sut = new IdentityService(NullLogger<IdentityService>.Instance, fx.AppOptions, settingsService);
var jwt = new JwtTokenData
{
UserId = admin.Id,
Username = admin.Name,
Token = "unused-for-patch",
IssuedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddMinutes(5)
};
var patch = new PatchUserRequest
{
Password = "new-secret",
Operations = new Dictionary<string, PatchOperation>
{
[nameof(PatchUserRequest.Password)] = PatchOperation.SetField
}
};
var patched = await sut.PatchUserAsync(jwt, admin.Id, patch);
Assert.True(patched.IsSuccess);
var oldLogin = await sut.LoginAsync(new LoginRequest { Username = "admin", Password = "password" });
Assert.False(oldLogin.IsSuccess);
var newLogin = await sut.LoginAsync(new LoginRequest { Username = "admin", Password = "new-secret" });
Assert.True(newLogin.IsSuccess);
}
}

View File

@ -0,0 +1,43 @@
using MaksIT.Webapi.Domain;
using MaksIT.Webapi.Services;
using MaksIT.Webapi.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace MaksIT.Webapi.Tests.Services;
public class SettingsServiceTests
{
[Fact]
public async Task LoadAsync_WhenFileMissing_ReturnsEmptySettings()
{
using var fx = new WebApiTestFixture();
var sut = new SettingsService(NullLogger<SettingsService>.Instance, fx.AppOptions);
var result = await sut.LoadAsync();
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.False(result.Value.Init);
Assert.Empty(result.Value.Users);
}
[Fact]
public async Task SaveAsync_and_LoadAsync_roundtrip_preserves_users()
{
using var fx = new WebApiTestFixture();
var sut = new SettingsService(NullLogger<SettingsService>.Instance, fx.AppOptions);
var init = new Settings().Initialize(fx.AppOptions.Value.Auth.Pepper);
Assert.True(init.IsSuccess);
var save = await sut.SaveAsync(init.Value!);
Assert.True(save.IsSuccess);
var loaded = await sut.LoadAsync();
Assert.True(loaded.IsSuccess);
Assert.NotNull(loaded.Value);
Assert.True(loaded.Value.Init);
Assert.Single(loaded.Value.Users);
Assert.Equal("admin", loaded.Value.Users[0].Name);
}
}

View File

@ -1,14 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<Version>3.3.4</Version>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
</PropertyGroup>
<ItemGroup>

View File

@ -1,4 +1,4 @@
using MaksIT.Core.Webapi.Models;
using MaksIT.Core.Webapi.Models;
using MaksIT.LetsEncrypt.Entities;
using MaksIT.Models.LetsEncryptServer.Account.Requests;
using MaksIT.Models.LetsEncryptServer.Account.Responses;
@ -185,7 +185,7 @@ public class AccountService(
return loadAccountResult.ToResultOfType<GetAccountResponse?>(_ => null);
}
return Result<GetAccountResponse?>.Ok(CreateGetAccountResponse(accountId, cache));
return Result<GetAccountResponse?>.Ok(CreateGetAccountResponse(accountId, loadAccountResult.Value));
}
public async Task<Result> DeleteAccountAsync(Guid accountId) {

File diff suppressed because it is too large Load Diff

View File

@ -1,77 +1,42 @@
{
"info": {
"_postman_id": "1e13f461-ccaa-436a-92e4-e14c05131b96",
"name": "Maks-IT Agent",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "33635244"
"name": "Maks-IT Agent (direct)",
"description": "Calls the Maks-IT Agent HTTP API directly (not through MaksIT.Webapi). Set collection variables agentBaseUrl (e.g. http://agent-host:5000) and agentKey.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{ "key": "agentBaseUrl", "value": "http://localhost:5000" },
{ "key": "agentKey", "value": "" },
{ "key": "serviceName", "value": "\"nginx\"" }
],
"item": [
{
"name": "reload service",
"request": {
"method": "POST",
"header": [
{
"key": "x-api-key",
"value": "{{agentKey}}"
},
{
"key": "Accept",
"value": "application/json"
},
{
"key": "Content-Type",
"value": "application/json"
}
{ "key": "x-api-key", "value": "{{agentKey}}" },
{ "key": "Accept", "value": "application/json" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\r\n \"serviceName\": {{serviceName}}\r\n}",
"options": {
"raw": {
"language": "json"
}
}
"raw": "{\n \"serviceName\": {{serviceName}}\n}",
"options": { "raw": { "language": "json" } }
},
"url": {
"raw": "http://lblsrv0001.corp.maks-it.com:5000/Service/Reload",
"protocol": "http",
"host": [
"lblsrv0001",
"corp",
"maks-it",
"com"
],
"port": "5000",
"path": [
"Service",
"Reload"
]
}
},
"response": []
"url": "{{agentBaseUrl}}/Service/Reload"
}
},
{
"name": "hello world",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "http://lblsrv0001.corp.maks-it.com:5000/HelloWorld",
"protocol": "http",
"host": [
"lblsrv0001",
"corp",
"maks-it",
"com"
],
"port": "5000",
"path": [
"HelloWorld"
]
}
},
"response": []
"header": [
{ "key": "x-api-key", "value": "{{agentKey}}" }
],
"url": "{{agentBaseUrl}}/HelloWorld"
}
}
]
}
}

View File

@ -1,9 +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.ps1"
pause

View File

@ -1,124 +0,0 @@
# HINT: To create a tag for the last commit in git, use:
# git tag 1.2.3
# git push origin 1.2.3
# Replace '1.2.3' with your desired tag name.
# Set variables
$projectName = "certs-ui"
$harborUrl = "cr.maks-it.com" # e.g., "harbor.yourdomain.com"
# Ensure we are on main branch and up to date
git checkout main
git pull
# Get the latest tag reachable from main
$tag = git describe --tags --abbrev=0
if (-not $tag) {
throw "No tags found on main branch."
}
$tags = @($tag, "latest")
Write-Output "Using tags: $($tags -join ', ')"
# Retrieve and decode username:password from environment variable (Base64 encoded)
try {
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Env:CR_MAKS_IT))
} catch {
throw "Failed to decode CR_MAKS_IT as Base64. Ensure it's base64('username:password'). Error: $_"
}
# Split decoded credentials
$creds = $decoded -split ':', 2
if ($creds.Count -ne 2) {
throw "Invalid decoded CR_MAKS_IT format. Expected 'username:password'."
}
$harborUsername = $creds[0]
$harborPassword = $creds[1]
# Authenticate with Harbor
Write-Output "Logging into $harborUrl as $harborUsername..."
$loginResult = $harborPassword | docker login $harborUrl -u $harborUsername --password-stdin 2>&1
if ($LASTEXITCODE -ne 0 -or ($loginResult -notmatch "Login Succeeded")) {
throw "Docker login failed for $harborUrl.`n$loginResult"
}
# List of services to build and push with the current context
$services = @{
"reverseproxy" = "ReverseProxy/Dockerfile"
"server" = "MaksIT.Webapi/Dockerfile"
"client" = "MaksIT.WebUI/Dockerfile.prod"
}
$contextPath = "."
foreach ($service in $services.Keys) {
$dockerfilePath = $services[$service]
$baseImageName = "$harborUrl/$projectName/${service}"
foreach ($t in $tags) {
$imageName = "$baseImageName`:$t"
Write-Output "Building image $imageName from $dockerfilePath..."
docker build -t $imageName -f $dockerfilePath $contextPath
if ($LASTEXITCODE -ne 0) {
throw "Docker build failed for $imageName"
}
Write-Output "Pushing image $imageName..."
docker push $imageName
if ($LASTEXITCODE -ne 0) {
throw "Docker push failed for $imageName"
}
}
}
# --- Helm Chart Release Section ---
# Backup Chart.yaml
Copy-Item "helm/Chart.yaml" "helm/Chart.yaml.bak" -Force
# Use the same tags, but choose the first non-'latest' for Helm
$helmTag = $tags | Where-Object { $_ -ne "latest" } | Select-Object -First 1
if (-not $helmTag) { throw "No valid SemVer tag found for Helm chart release." }
Write-Output "Using Helm chart version: $helmTag"
# Update Chart.yaml version and appVersion
$content = Get-Content "helm/Chart.yaml" -Raw
$content = $content `
-replace '(?m)^\s*version:\s*.*$', "version: $helmTag" `
-replace '(?m)^\s*appVersion:\s*.*$', "appVersion: $helmTag"
Set-Content "helm/Chart.yaml" $content
# Package the Helm chart
$chartDir = "helm"
$chartPackageOutput = helm package $chartDir
$chartPackage = $null
if ($chartPackageOutput -match "Successfully packaged chart and saved it to: (.+\.tgz)") {
$chartPackage = $Matches[1]
}
if (-not $chartPackage) {
throw "Helm chart packaging failed. Output: $chartPackageOutput"
}
# Push the Helm chart to Harbor
$helmRepoUrl = "oci://$harborUrl/charts"
Write-Output "Pushing Helm chart $chartPackage to $helmRepoUrl..."
helm push $chartPackage $helmRepoUrl --username $harborUsername --password $harborPassword
if ($LASTEXITCODE -ne 0) {
throw "Helm chart push failed."
}
if ($chartPackage) {
Remove-Item $chartPackage -Force
Write-Output "Cleaned up $chartPackage"
}
# Restore Chart.yaml
Move-Item "helm/Chart.yaml.bak" "helm/Chart.yaml" -Force
docker logout $harborUrl | Out-Null
Write-Output "Completed successfully."

View File

@ -1,14 +0,0 @@
@echo off
setlocal
REM Get the directory of the current script
set "SCRIPT_DIR=%~dp0"
REM Invoke the PowerShell script (Release-NuGetPackage.ps1) in the same directory
powershell -ExecutionPolicy Bypass -File "%~dp0Release.ps1"
REM Invoke the PowerShell script (Release-NuGetPackage.ps1) in the same directory
powershell -ExecutionPolicy Bypass -File "%~dp0Deploy-Helm.ps1"
echo All scripts completed.
pause

View File

@ -2,5 +2,7 @@ apiVersion: v2
name: certs-ui
description: MaksIT CertsUI
type: application
version: 0.1.0
appVersion: "latest"
# In git, version/appVersion are placeholders only. HelmPush (Release-Package) replaces
# them with the app semver from the release pipeline (git tag / DotNetReleaseVersion) before package and push.
version: 0.0.0
appVersion: "0.0.0"

View File

@ -18,11 +18,15 @@ Port-forward API example:
------------------------------------------------------------
## Images
Image tag: `components.*.image.tag`, then `global.image.tag`, then Chart `appVersion`. Change tag and run `helm upgrade` to roll out.
Image tag: `components.*.image.tag`, then `global.image.tag`, then Chart `appVersion`.
`pullPolicy: Always` helps with a moving tag (e.g. latest); pinned tags often use `IfNotPresent`.
**imagePullPolicy** resolves as: `components.*.image.pullPolicy` (if set in your values), else `global.image.pullPolicy`, else `IfNotPresent`. Values may use **`always`** / **`Always`** (normalized for the Pod spec).
Pod annotation `rollme` tracks Helm release revision.
With **`Always`**, the chart sets pod annotation **`rollme`**: by default **`r<Release.Revision>-<unixEpoch>`** so each **`helm upgrade`** bumps the revision even when the tag is unchanged. For **`helm template`** output committed to git, revision is usually **1** and epoch is frozen until you re-render — then pass **`global.rolloutNonce`** from CI (unique per deploy, e.g. pipeline id) so the applied manifest changes every image push. Pin **`global.rollme`** to a string you bump when you need a stable, deterministic rollout key.
With **`IfNotPresent`** (default), **`rollme`** is omitted; the node may keep a cached layer for a mutable tag even if you replace the tag in the registry.
Pod annotation **certs-ui.io/image** is the resolved `registry/repository:tag` for debugging.
------------------------------------------------------------
## Config

View File

@ -27,6 +27,12 @@ app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
helm.sh/chart: {{ include "certs-ui.chart" . }}
{{- end }}
{{- /* Kubernetes imagePullPolicy; accepts common casing (always / IfNotPresent). */ -}}
{{- define "certs-ui.normalizePullPolicy" -}}
{{- $in := . | toString | trim | lower -}}
{{- if eq $in "always" -}}Always{{- else if eq $in "never" -}}Never{{- else -}}IfNotPresent{{- end -}}
{{- end }}
{{- /* Image pull secrets (global) -> list of names) */ -}}
{{- define "certs-ui.imagePullSecrets" -}}
{{- $ips := default (list) .Values.global.imagePullSecrets -}}

View File

@ -1,6 +1,15 @@
{{- $root := . -}}
{{- $globalPull := $root.Values.global.image.pullPolicy | default "" }}
{{- range $compName, $comp := .Values.components }}
{{- $imageTag := include "certs-ui.component.imageTag" (dict "root" $root "comp" $comp) }}
{{- $pullPolicy := include "certs-ui.normalizePullPolicy" (coalesce $comp.image.pullPolicy $globalPull "IfNotPresent") | trim }}
{{- $alwaysPull := eq $pullPolicy "Always" }}
{{- $rollEpoch := now | unixEpoch | toString }}
{{- $rollRev := $root.Release.Revision | toString }}
{{- $rollPin := $root.Values.global.rollme | default "" | toString | trim }}
{{- $rollNonce := $root.Values.global.rolloutNonce | default "" | toString | trim }}
{{- /* rollPin wins; else nonce-epoch for CI; else release revision + epoch (helm upgrade bumps revision; re-render bumps epoch). */ -}}
{{- $rollmeVal := ternary $rollPin (ternary (printf "%s-%s" $rollNonce $rollEpoch) (printf "r%s-%s" $rollRev $rollEpoch) (ne $rollNonce "")) (ne $rollPin "") }}
{{- $cf := default dict $comp.configMapFile }}
{{- $sf := default dict $comp.secretsFile }}
{{- $cmName := ternary $cf.existingConfigMap (printf "%s-%s-configmap" (include "certs-ui.fullname" $root) $compName) (ne ($cf.existingConfigMap | default "") "") }}
@ -33,13 +42,17 @@ spec:
labels:
{{ include "certs-ui.podLabels" (dict "root" $root "component" $compName "imageTag" $imageTag) | indent 8 }}
annotations:
rollme: {{ $root.Release.Revision | quote }}
{{- /* With Always: changing rollme forces a rollout so new pods run imagePullPolicy Always against the registry (same tag). Pin global.rollme for stable manifests. */ -}}
{{- if $alwaysPull }}
rollme: {{ $rollmeVal | quote }}
{{- end }}
certs-ui.io/image: {{ printf "%s/%s:%s" $comp.image.registry $comp.image.repository $imageTag | quote }}
spec:
{{- include "certs-ui.imagePullSecrets" $root | nindent 6 }}
containers:
- name: {{ $compName }}
image: "{{ $comp.image.registry }}/{{ $comp.image.repository }}:{{ $imageTag }}"
imagePullPolicy: {{ default "IfNotPresent" $comp.image.pullPolicy }}
imagePullPolicy: {{ $pullPolicy }}
{{ $svc := default dict $comp.service }}
{{ $tgt := default 8080 $svc.targetPort }}
ports:

View File

@ -4,12 +4,21 @@
{{- $vols := default (list) $p.volumes }}
{{- range $vol := $vols }}
{{- if and (eq $vol.type "pvc") $vol.pvc.create }}
{{- $keepPvc := true -}}
{{- if and $vol.pvc (hasKey $vol.pvc "keep") -}}
{{- $keepPvc = $vol.pvc.keep -}}
{{- end }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "certs-ui.fullname" $root }}-{{ $compName }}-{{ $vol.name }}
namespace: {{ $root.Release.Namespace }}
{{- if $keepPvc }}
annotations:
helm.sh/resource-policy: keep
{{- end }}
labels:
{{- include "certs-ui.labels" $root | nindent 4 }}
app.kubernetes.io/component: {{ $compName }}
@ -19,6 +28,6 @@ spec:
requests:
storage: {{ default "1Gi" $vol.pvc.size }}
storageClassName: {{ default $.Values.components.server.persistence.storageClass $vol.pvc.storageClass | quote }}
{{- end }}
{{ end }}
{{- end }}
{{- end }}

View File

@ -2,6 +2,9 @@ global:
imagePullSecrets: []
image:
tag: "" # used if component image.tag is empty; else Chart appVersion
# pullPolicy: always # optional; default IfNotPresent. Use always (any casing) for same-tag registry updates.
# rollme: "" # optional; when Always — fixed rollme; bump manually to roll
# rolloutNonce: "" # optional; when Always — set per CI run (e.g. build id) if you apply frozen manifests (git-rendered YAML); changes rollme without re-helm timing
nameOverride: ""
fullnameOverride: ""
@ -45,7 +48,6 @@ components:
registry: cr.maks-it.com
repository: certs-ui/server
tag: ""
pullPolicy: IfNotPresent
env:
- name: ASPNETCORE_ENVIRONMENT
value: Production
@ -64,6 +66,7 @@ components:
type: pvc
pvc:
create: true
keep: true
size: 50Mi
accessModes: [ReadWriteOnce]
- name: cache
@ -71,6 +74,7 @@ components:
type: pvc
pvc:
create: true
keep: true
size: 50Mi
accessModes: [ReadWriteOnce]
- name: data
@ -78,6 +82,7 @@ components:
type: pvc
pvc:
create: true
keep: true
size: 50Mi
accessModes: [ReadWriteOnce]
secretsFile:
@ -139,7 +144,6 @@ components:
registry: cr.maks-it.com
repository: certs-ui/client
tag: ""
pullPolicy: IfNotPresent
service:
enabled: true
type: ClusterIP
@ -161,7 +165,6 @@ components:
registry: cr.maks-it.com
repository: certs-ui/reverseproxy
tag: ""
pullPolicy: IfNotPresent
env:
- name: ASPNETCORE_ENVIRONMENT
value: Production

View File

@ -10,9 +10,10 @@
SVG badges for line, branch, and method coverage.
Configuration is stored in scriptsettings.json:
- openReport : Generate and open full HTML report (true/false)
- paths.testProject : Relative path to test project
- paths.badgesDir : Relative path to badges output directory
- openReport : Generate and open full HTML report (true/false)
- paths.testProjects : Array of relative paths to test projects (preferred)
- paths.testProject : Single test project path (legacy; use testProjects)
- paths.badgesDir : Relative path to badges output directory
- badges : Array of badges to generate (name, label, metric)
- colorThresholds : Coverage percentages for badge colors
@ -81,8 +82,23 @@ $thresholds = $Settings.colorThresholds
# Runtime options from settings
$OpenReport = if ($null -ne $Settings.openReport) { [bool]$Settings.openReport } else { $false }
# Resolve configured paths to absolute paths
$TestProjectPath = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.testProject))
# Resolve configured paths to absolute paths (one or more test projects)
$testProjectPaths = [System.Collections.Generic.List[string]]::new()
$pathsNode = $Settings.paths
if ($pathsNode.PSObject.Properties.Name -contains 'testProjects' -and $pathsNode.testProjects) {
foreach ($rel in @($pathsNode.testProjects)) {
if ([string]::IsNullOrWhiteSpace([string]$rel)) { continue }
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $ScriptDir $rel.Trim())))
}
}
if ($testProjectPaths.Count -eq 0 -and $pathsNode.testProject) {
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $ScriptDir $pathsNode.testProject)))
}
if ($testProjectPaths.Count -eq 0) {
Write-Error "Configure paths.testProjects (array of relative paths) or paths.testProject (single path) in scriptsettings.json."
exit 1
}
$BadgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.badgesDir))
# Ensure badges directory exists
@ -163,7 +179,15 @@ function New-Badge {
#region Test And Coverage
$coverage = Invoke-TestsWithCoverage -TestProjectPath $TestProjectPath -KeepResults:$OpenReport
$invokeCoverageParams = @{
TestProjectPath = @($testProjectPaths)
KeepResults = $OpenReport
}
# Keep single-project results next to that project; for several projects use a shared folder under this script.
if ($testProjectPaths.Count -gt 1) {
$invokeCoverageParams.ResultsDirectory = Join-Path $ScriptDir "TestResults"
}
$coverage = Invoke-TestsWithCoverage @invokeCoverageParams
if (-not $coverage.Success) {
Write-Error "Tests failed: $($coverage.Error)"
exit 1
@ -210,10 +234,17 @@ Write-Log -Level "STEP" -Message "Commit the badges/ folder to update README."
if ($OpenReport -and $coverage.CoverageFile) {
Write-LogStep -Message "Generating HTML report..."
Assert-Command reportgenerator
$ResultsDir = Split-Path (Split-Path $coverage.CoverageFile -Parent) -Parent
# Cobertura file(s): single path, or semicolon-separated list from merged runs.
$firstCobertura = if ($coverage.CoverageFiles -and $coverage.CoverageFiles.Count -gt 0) {
$coverage.CoverageFiles[0]
}
else {
($coverage.CoverageFile -split ';')[0].Trim()
}
$ResultsDir = Split-Path (Split-Path $firstCobertura -Parent) -Parent
$ReportDir = Join-Path $ResultsDir "report"
$reportGenArgs = @(
"-reports:$($coverage.CoverageFile)"
"-targetdir:$ReportDir"

View File

@ -4,7 +4,10 @@
"description": "Configuration for Generate-CoverageBadges.ps1 script",
"openReport": false,
"paths": {
"testProject": "..\\..\\src\\MaksIT.Core.Tests",
"testProjects": [
"..\\..\\src\\LetsEncrypt.Tests",
"..\\..\\src\\MaksIT.Webapi.Tests"
],
"badgesDir": "..\\..\\assets\\badges"
},
"badges": [
@ -35,7 +38,8 @@
"_comments": {
"openReport": "If true, generate and open full HTML coverage report (requires reportgenerator tool).",
"paths": {
"testProject": "Relative path to test project used by TestRunner.",
"testProjects": "Array of relative paths (from this folder) to test project directories or .csproj files. All are run; badge metrics aggregate Cobertura line/branch/method stats.",
"testProject": "Optional legacy single path if testProjects is omitted.",
"badgesDir": "Relative path where SVG coverage badges are written."
},
"badges": "List of output badges. Each entry maps a metric key (line|branch|method) to filename and label.",

View File

@ -69,8 +69,8 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$sharedSettings = $Settings.context
$artifactsDirectory = $sharedSettings.artifactsDirectory
$patterns = Get-CleanupPatternsInternal -ConfiguredPatterns $pluginSettings.includePatterns
$excludePatterns = Get-ExcludePatternsInternal -ConfiguredPatterns $pluginSettings.excludePatterns

View File

@ -27,18 +27,18 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$version = $sharedSettings.Version
$sharedSettings = $Settings.context
$artifactsDirectory = $sharedSettings.artifactsDirectory
$version = $sharedSettings.version
$archiveInputs = @()
if ($sharedSettings.PSObject.Properties['ReleaseArchiveInputs'] -and $sharedSettings.ReleaseArchiveInputs) {
$archiveInputs = @($sharedSettings.ReleaseArchiveInputs)
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
elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) {
$archiveInputs = @($sharedSettings.packageFile.FullName)
if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) {
$archiveInputs += $sharedSettings.symbolsPackageFile.FullName
}
}
@ -78,16 +78,16 @@ function Invoke-Plugin {
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['packageFile'] -and $sharedSettings.packageFile) {
$releaseAssetPaths += $sharedSettings.packageFile.FullName
}
if ($sharedSettings.PSObject.Properties['SymbolsPackageFile'] -and $sharedSettings.SymbolsPackageFile) {
$releaseAssetPaths += $sharedSettings.SymbolsPackageFile.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
$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

View File

@ -0,0 +1,175 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Build and push Docker images to a container registry.
.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); not from Chart.yaml.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "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 docker
if ([string]::IsNullOrWhiteSpace($pluginSettings.registryUrl)) {
throw "DockerPush plugin requires 'registryUrl' (registry hostname, no scheme)."
}
if ([string]::IsNullOrWhiteSpace($pluginSettings.credentialsEnvVar)) {
throw "DockerPush plugin requires 'credentialsEnvVar' (name of env var holding base64 username:password)."
}
if ([string]::IsNullOrWhiteSpace($pluginSettings.projectName)) {
throw "DockerPush plugin requires 'projectName' (image path segment after registry)."
}
if ([string]::IsNullOrWhiteSpace($pluginSettings.contextPath)) {
throw "DockerPush plugin requires 'contextPath' (Docker build context, relative to Release-Package folder)."
}
if (-not $pluginSettings.images -or @($pluginSettings.images).Count -eq 0) {
throw "DockerPush 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 "DockerPush: 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'."
}
$dockerfileRel = [string]$img.dockerfile
$dockerfilePath = [System.IO.Path]::GetFullPath((Join-Path $contextPath $dockerfileRel))
if (-not (Test-Path $dockerfilePath -PathType Leaf)) {
throw "Dockerfile not found: $dockerfilePath"
}
$service = [string]$img.service
$baseName = "$registryUrl/$($pluginSettings.projectName)/$service"
$primaryRef = "${baseName}:$($imageTags[0])"
Write-Log -Level "STEP" -Message "Building $primaryRef ..."
docker build -t $primaryRef -f $dockerfilePath $contextPath
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 {
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

View File

@ -3,7 +3,7 @@
<#
.SYNOPSIS
NuGet publish plugin.
.NET NuGet publish plugin.
.DESCRIPTION
This plugin publishes the package artifact from shared runtime
@ -27,18 +27,18 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$sharedSettings = $Settings.context
$nugetApiKeyEnvVar = $pluginSettings.nugetApiKey
$packageFile = $sharedSettings.PackageFile
$packageFile = $sharedSettings.packageFile
Assert-Command dotnet
if (-not $packageFile) {
throw "NuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running NuGet."
throw "DotNetNuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running DotNetNuGet."
}
if ([string]::IsNullOrWhiteSpace($nugetApiKeyEnvVar)) {
throw "NuGet plugin requires 'nugetApiKey' in scriptsettings.json."
throw "DotNetNuGet plugin requires 'nugetApiKey' in scriptsettings.json."
}
$nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar)
@ -53,15 +53,18 @@ function Invoke-Plugin {
$pluginSettings.source
}
Write-Log -Level "STEP" -Message "Pushing to NuGet.org..."
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."
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
$sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force
}
Export-ModuleMember -Function Invoke-Plugin

View File

@ -29,18 +29,35 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "ReleaseContext" -RequiredCommand "Resolve-RelativePaths"
$sharedSettings = $Settings.Context
$projectFiles = $sharedSettings.ProjectFiles
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$version = $sharedSettings.Version
$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 (-not $sharedSettings.PSObject.Properties['ProjectFiles'] -or $projectFiles.Count -eq 0) {
throw "DotNetPack plugin requires project files in the shared context."
if ($projectFiles.Count -eq 0) {
throw "DotNetPack plugin requires projectFiles in plugin settings or projectFiles on shared context."
}
$outputDir = $artifactsDirectory
@ -49,27 +66,31 @@ function Invoke-Plugin {
New-Item -ItemType Directory -Path $outputDir | Out-Null
}
# The release context guarantees ProjectFiles is an array, so index 0 is the first project path,
# not the first character of a string.
$packageProjectPath = $projectFiles[0]
# First path in the configured project list is the pack target.
$packageProjectPath = (@($projectFiles))[0]
Write-Log -Level "STEP" -Message "Packing NuGet package..."
dotnet pack $packageProjectPath -c Release -o $outputDir --nologo `
-p:IncludeSymbols=true `
-p:SymbolPackageFormat=snupkg
$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 = Get-ChildItem -Path $outputDir -Filter "*.nupkg" |
Where-Object {
$_.Name -like "*$version*.nupkg" -and
$_.Name -notlike "*.symbols.nupkg" -and
$_.Name -notlike "*.snupkg"
} |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
$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"
@ -78,10 +99,17 @@ function Invoke-Plugin {
Write-Log -Level "OK" -Message " Package ready: $($packageFile.FullName)"
$releaseArchiveInputs = @($packageFile.FullName)
$symbolsPackageFile = Get-ChildItem -Path $outputDir -Filter "*.snupkg" |
Where-Object { $_.Name -like "*$version*.snupkg" } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
$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)"
@ -91,9 +119,9 @@ function Invoke-Plugin {
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
$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

View File

@ -27,14 +27,14 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$sharedSettings = $Settings.Context
$projectFiles = $sharedSettings.ProjectFiles
$artifactsDirectory = $sharedSettings.ArtifactsDirectory
$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) {
if (-not $sharedSettings.PSObject.Properties['projectFiles'] -or $projectFiles.Count -eq 0) {
throw "DotNetPublish plugin requires project files in the shared context."
}
@ -63,9 +63,9 @@ function Invoke-Plugin {
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
$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

View File

@ -0,0 +1,40 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Loads release version into shared context.
.DESCRIPTION
Dedicated version-loading plugin. It reads .csproj version via
ReleaseContext helpers and writes Version into the shared runtime context.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "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 "ReleaseContext" -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

View File

@ -6,9 +6,12 @@
.NET test plugin for executing automated tests.
.DESCRIPTION
This plugin resolves the configured .NET test project and optional
results directory, runs tests through TestRunner, and stores
the resulting test metrics in shared runtime context.
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)) {
@ -29,26 +32,37 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "TestRunner" -RequiredCommand "Invoke-TestsWithCoverage"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$testProjectSetting = $pluginSettings.project
$sharedSettings = $Settings.context
$testResultsDirSetting = $pluginSettings.resultsDir
$scriptDir = $sharedSettings.ScriptDir
$scriptDir = $sharedSettings.scriptDir
if ([string]::IsNullOrWhiteSpace($testProjectSetting)) {
throw "DotNetTest plugin requires 'project' in scriptsettings.json."
$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 }
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $rel.Trim())))
}
}
if ($testProjectPaths.Count -eq 0 -and $pluginSettings.project) {
$testProjectPaths.Add([System.IO.Path]::GetFullPath((Join-Path $scriptDir $pluginSettings.project)))
}
if ($testProjectPaths.Count -eq 0) {
throw "DotNetTest plugin requires 'project' or 'projects' in scriptsettings.json."
}
$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $testProjectSetting))
$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 = $testProjectPath
TestProjectPath = @($testProjectPaths)
Silent = $true
}
if ($testResultsDir) {
@ -61,7 +75,19 @@ function Invoke-Plugin {
throw "Tests failed. $($testResult.Error)"
}
$sharedSettings | Add-Member -NotePropertyName TestResult -NotePropertyValue $testResult -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.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)%"

View File

@ -43,7 +43,7 @@ function Get-GitHubRepositoryInternal {
return "$($matches['owner'])/$($matches['repo'])"
}
throw "Could not parse GitHub repo from source: $repoSource. Configure Plugins[].repository with 'owner/repo' or a GitHub URL."
throw "Could not parse GitHub repo from source: $repoSource. Configure plugins[].repository with 'owner/repo' or a GitHub URL."
}
function Get-ReleaseNotesInternal {
@ -94,15 +94,15 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$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
$scriptDir = $sharedSettings.scriptDir
$version = $sharedSettings.version
$tag = $sharedSettings.tag
$releaseDir = $sharedSettings.releaseDir
$releaseAssetPaths = @()
Assert-Command gh
@ -123,13 +123,13 @@ function Invoke-Plugin {
$releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting))
$releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version
if ($sharedSettings.PSObject.Properties['ReleaseAssetPaths'] -and $sharedSettings.ReleaseAssetPaths) {
$releaseAssetPaths = @($sharedSettings.ReleaseAssetPaths)
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
elseif ($sharedSettings.PSObject.Properties['packageFile'] -and $sharedSettings.packageFile) {
$releaseAssetPaths = @($sharedSettings.packageFile.FullName)
if ($sharedSettings.PSObject.Properties['symbolsPackageFile'] -and $sharedSettings.symbolsPackageFile) {
$releaseAssetPaths += $sharedSettings.symbolsPackageFile.FullName
}
}
@ -217,7 +217,7 @@ function Invoke-Plugin {
}
Write-Log -Level "OK" -Message " GitHub release created successfully."
$sharedSettings | Add-Member -NotePropertyName PublishCompleted -NotePropertyValue $true -Force
$sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force
}
finally {
if ($null -ne $previousGhToken) {

View File

@ -0,0 +1,180 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Package a Helm chart and push it to an OCI registry.
.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)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "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 "HelmPush plugin requires 'chartPath' (chart directory, relative to Release-Package folder)."
}
if ([string]::IsNullOrWhiteSpace($pluginSettings.ociRepository)) {
throw "HelmPush plugin requires 'ociRepository' (e.g. oci://cr.maks-it.com/charts)."
}
if ([string]::IsNullOrWhiteSpace($pluginSettings.credentialsEnvVar)) {
throw "HelmPush 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

View File

@ -3,12 +3,23 @@
<#
.SYNOPSIS
Quality gate plugin for validating release readiness.
Quality gate plugin (coverage threshold + optional .NET vulnerability scan).
.DESCRIPTION
This plugin evaluates quality constraints using shared test
results and project files. It enforces coverage thresholds
and checks for vulnerable packages before release plugins run.
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 module: CorePlugins/QualityGate.psm1 (`"name": "QualityGate"`).
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
@ -46,6 +57,32 @@ function Test-VulnerablePackagesInternal {
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)]
@ -54,19 +91,26 @@ function Invoke-Plugin {
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command"
Import-PluginDependency -ModuleName "ReleaseContext" -RequiredCommand "Resolve-RelativePaths"
$pluginSettings = $Settings
$sharedSettings = $Settings.Context
$sharedSettings = $Settings.context
$scriptDir = $sharedSettings.scriptDir
$coverageThresholdSetting = $pluginSettings.coverageThreshold
$failOnVulnerabilitiesSetting = $pluginSettings.failOnVulnerabilities
$projectFiles = $sharedSettings.ProjectFiles
$testResult = $null
if ($sharedSettings.PSObject.Properties['TestResult']) {
$testResult = $sharedSettings.TestResult
$scanVulnerabilities = $true
if ($null -ne $pluginSettings.scanVulnerabilities) {
$scanVulnerabilities = [bool]$pluginSettings.scanVulnerabilities
}
if ($null -eq $testResult) {
throw "QualityGate plugin requires test results. Run the DotNetTest plugin first."
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
@ -74,16 +118,33 @@ function Invoke-Plugin {
$coverageThreshold = [double]$coverageThresholdSetting
}
if ($coverageThreshold -gt 0) {
Write-Log -Level "STEP" -Message "Checking coverage threshold..."
if ([double]$testResult.LineRate -lt $coverageThreshold) {
throw "Line coverage $($testResult.LineRate)% is below the configured threshold of $coverageThreshold%."
$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 "OK" -Message " Coverage threshold met: $($testResult.LineRate)% >= $coverageThreshold%"
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 "WARN" -Message "Skipping coverage threshold check (disabled)."
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
@ -93,6 +154,10 @@ function Invoke-Plugin {
$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) {

View File

@ -0,0 +1,161 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Central gate for publish-stage plugins (Docker, Helm, GitHub, NuGet).
.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)) {
$pluginSupportModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) "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"
$pluginSupportPath = Join-Path (Split-Path $PSScriptRoot -Parent) "PluginSupport.psm1"
Import-Module $pluginSupportPath -Force -Global -ErrorAction Stop
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.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 DotNet 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

View File

@ -1,110 +0,0 @@
#requires -Version 7.0
#requires -PSEdition Core
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
}
}
if (-not (Get-Command Get-PluginPathListSetting -ErrorAction SilentlyContinue)) {
$pluginSupportModulePath = Join-Path $PSScriptRoot "PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force
}
}
function Get-DotNetProjectPropertyValue {
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-DotNetProjectVersions {
param(
[Parameter(Mandatory = $true)]
[string[]]$ProjectFiles
)
Write-Log -Level "INFO" -Message "Reading version(s) from .NET project files..."
$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-DotNetProjectPropertyValue -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 New-DotNetReleaseContext {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$ScriptDir
)
# The array wrapper is intentional: without it, one configured project can collapse to a string,
# and later indexing [0] would return only the first character of the path.
$projectFiles = @(Get-PluginPathListSetting -Plugins $Plugins -PropertyName "projectFiles" -BasePath $ScriptDir)
$artifactsDirectory = Get-PluginPathSetting -Plugins $Plugins -PropertyName "artifactsDir" -BasePath $ScriptDir
if ($projectFiles.Count -eq 0) {
Write-Error "No .NET project files configured in plugin settings. Add 'projectFiles' to a relevant plugin."
exit 1
}
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
Write-Error "No artifacts directory configured in plugin settings. Add 'artifactsDir' to a relevant plugin."
exit 1
}
$projectVersions = Get-DotNetProjectVersions -ProjectFiles $projectFiles
# The first configured project is treated as the canonical version source for the release.
$version = $projectVersions[$projectFiles[0]]
return [pscustomobject]@{
ProjectFiles = $projectFiles
ArtifactsDirectory = $artifactsDirectory
Version = $version
}
}
Export-ModuleMember -Function Get-DotNetProjectPropertyValue, Get-DotNetProjectVersions, New-DotNetReleaseContext

View File

@ -15,36 +15,29 @@ if (-not (Get-Command Get-CurrentBranch -ErrorAction SilentlyContinue)) {
}
}
if (-not (Get-Command Get-PluginStage -ErrorAction SilentlyContinue) -or -not (Get-Command Test-IsPublishPlugin -ErrorAction SilentlyContinue)) {
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 New-DotNetReleaseContext -ErrorAction SilentlyContinue)) {
$dotNetProjectSupportModulePath = Join-Path $PSScriptRoot "DotNetProjectSupport.psm1"
if (Test-Path $dotNetProjectSupportModulePath -PathType Leaf) {
Import-Module $dotNetProjectSupportModulePath -Force
if (-not (Get-Command Resolve-DotNetReleaseVersion -ErrorAction SilentlyContinue)) {
$releaseContextModulePath = Join-Path $PSScriptRoot "ReleaseContext.psm1"
if (Test-Path $releaseContextModulePath -PathType Leaf) {
Import-Module $releaseContextModulePath -Force
}
}
function Assert-WorkingTreeClean {
param(
[Parameter(Mandatory = $true)]
[bool]$IsReleaseBranch
)
$gitStatus = Get-GitStatusShort
if ($gitStatus) {
if ($IsReleaseBranch) {
Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing."
Write-Log -Level "WARN" -Message "Uncommitted files:"
$gitStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" }
exit 1
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"
}
}
Write-Log -Level "WARN" -Message " Uncommitted changes detected (allowed on dev branch)."
return
}
@ -66,18 +59,8 @@ function Initialize-ReleaseStageContext {
[string]$Version
)
Write-Log -Level "STEP" -Message "Verifying tag is pushed to remote..."
$remoteTagExists = Test-RemoteTagExists -Tag $SharedSettings.Tag -Remote "origin"
if (-not $remoteTagExists) {
Write-Log -Level "WARN" -Message " Tag $($SharedSettings.Tag) not found on remote. Pushing..."
Push-TagToRemote -Tag $SharedSettings.Tag -Remote "origin"
}
else {
Write-Log -Level "OK" -Message " Tag exists on remote."
}
if (-not $SharedSettings.PSObject.Properties['ReleaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.ReleaseDir)) {
$SharedSettings | Add-Member -NotePropertyName ReleaseDir -NotePropertyValue $ArtifactsDirectory -Force
if (-not $SharedSettings.PSObject.Properties['releaseDir'] -or [string]::IsNullOrWhiteSpace([string]$SharedSettings.releaseDir)) {
$SharedSettings | Add-Member -NotePropertyName releaseDir -NotePropertyValue $ArtifactsDirectory -Force
}
}
@ -90,62 +73,60 @@ function New-EngineContext {
[string]$ScriptDir,
[Parameter(Mandatory = $true)]
[string]$UtilsDir
[string]$UtilsDir,
[Parameter(Mandatory = $false)]
[psobject]$Settings
)
$dotNetContext = New-DotNetReleaseContext -Plugins $Plugins -ScriptDir $ScriptDir
$version = (Resolve-DotNetReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir).version
$artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir '..\\..\\release'))
$currentBranch = Get-CurrentBranch
$releaseBranches = @(
$Plugins |
Where-Object { Test-IsPublishPlugin -Plugin $_ } |
ForEach-Object { Get-PluginBranches -Plugin $_ } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Select-Object -Unique
)
# Hint branches for messaging: ReleasePublishGuard.branches if present, else publish plugins' branches, else main.
$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 -IsReleaseBranch:$isReleaseBranch
Assert-WorkingTreeClean
$version = $dotNetContext.Version
if ($isReleaseBranch) {
$tag = Get-CurrentCommitTag -Version $version
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)."
exit 1
}
$tagVersion = $Matches[1]
if ($tagVersion -ne $version) {
Write-Error "Tag version ($tagVersion) does not match the project version ($version)."
Write-Log -Level "WARN" -Message " Either update the tag or the project version."
exit 1
}
Write-Log -Level "OK" -Message " Tag found: $tag (matches project version)"
}
else {
$tag = "v$version"
Write-Log -Level "INFO" -Message " Using version from the package project (no tag required on non-release branches)."
}
$tag = "v$version"
Write-Log -Level "INFO" -Message " Release tag default from DotNetReleaseVersion: $tag (ReleasePublishGuard may replace from git when publish is allowed)."
return [pscustomobject]@{
ScriptDir = $ScriptDir
UtilsDir = $UtilsDir
CurrentBranch = $currentBranch
Version = $version
Tag = $tag
ProjectFiles = $dotNetContext.ProjectFiles
ArtifactsDirectory = $dotNetContext.ArtifactsDirectory
IsReleaseBranch = $isReleaseBranch
IsNonReleaseBranch = $isNonReleaseBranch
ReleaseBranches = $releaseBranches
NonReleaseBranches = @()
PublishCompleted = $false
scriptDir = $ScriptDir
utilsDir = $UtilsDir
currentBranch = $currentBranch
version = $version
tag = $tag
artifactsDirectory = $artifactsDirectory
isReleaseBranch = $isReleaseBranch
isNonReleaseBranch = $isNonReleaseBranch
releaseBranches = $releaseBranches
publishCompleted = $false
skipPublishPlugins = $false
}
}
@ -155,11 +136,14 @@ function Get-PreferredReleaseBranch {
[psobject]$EngineContext
)
if ($EngineContext.ReleaseBranches.Count -gt 0) {
return $EngineContext.ReleaseBranches[0]
if ($EngineContext.releaseBranches.Count -gt 0) {
return $EngineContext.releaseBranches[0]
}
return "main"
}
Export-ModuleMember -Function Assert-WorkingTreeClean, Initialize-ReleaseStageContext, New-EngineContext, Get-PreferredReleaseBranch

View File

@ -40,30 +40,30 @@ function Get-ConfiguredPlugins {
[psobject]$Settings
)
if (-not $Settings.PSObject.Properties['Plugins'] -or $null -eq $Settings.Plugins) {
if (-not $Settings.PSObject.Properties['plugins'] -or $null -eq $Settings.plugins) {
return @()
}
# JSON can deserialize a single plugin as one object or multiple plugins as an array.
# Always return an array so the engine can loop without special-case logic.
if ($Settings.Plugins -is [System.Collections.IEnumerable] -and -not ($Settings.Plugins -is [string])) {
return @($Settings.Plugins)
if ($Settings.plugins -is [System.Collections.IEnumerable] -and -not ($Settings.plugins -is [string])) {
return @($Settings.plugins)
}
return @($Settings.Plugins)
return @($Settings.plugins)
}
function Get-PluginStage {
function Get-PluginStageLabel {
param(
[Parameter(Mandatory = $true)]
$Plugin
)
if (-not $Plugin.PSObject.Properties['Stage'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.Stage)) {
return "Release"
if (-not $Plugin.PSObject.Properties['stageLabel'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.stageLabel)) {
return 'release'
}
return [string]$Plugin.Stage
return [string]$Plugin.stageLabel
}
function Get-PluginBranches {
@ -88,17 +88,38 @@ function Get-PluginBranches {
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)) {
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) {
return $false
}
return @('GitHub', 'NuGet') -contains ([string]$Plugin.Name)
return @('GitHub', 'DotNetNuGet', 'DockerPush', 'HelmPush') -contains ([string]$Plugin.name)
}
function Get-PluginSettingValue {
@ -111,7 +132,7 @@ function Get-PluginSettingValue {
)
foreach ($plugin in $Plugins) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.Name)) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
continue
}
@ -204,16 +225,15 @@ function Get-ArchiveNamePattern {
)
foreach ($plugin in $Plugins) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.Name)) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
continue
}
if (-not $plugin.Enabled) {
if (-not $plugin.enabled) {
continue
}
$allowedBranches = Get-PluginBranches -Plugin $plugin
if ($allowedBranches.Count -gt 0 -and -not ($allowedBranches -contains $CurrentBranch)) {
if (-not (Test-PluginAllowedOnBranch -Plugin $plugin -CurrentBranch $CurrentBranch)) {
continue
}
@ -234,7 +254,7 @@ function Resolve-PluginModulePath {
[string]$PluginsDirectory
)
$pluginFileName = "{0}.psm1" -f $Plugin.Name
$pluginFileName = "{0}.psm1" -f $Plugin.name
$candidatePaths = @(
(Join-Path $PluginsDirectory $pluginFileName),
(Join-Path (Join-Path (Split-Path $PluginsDirectory -Parent) "CustomPlugins") $pluginFileName)
@ -264,37 +284,20 @@ function Test-PluginRunnable {
[bool]$WriteLogs = $true
)
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.Name)) {
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.name)) {
if ($WriteLogs) {
Write-Log -Level "WARN" -Message "Skipping plugin entry with no Name."
Write-Log -Level "WARN" -Message "Skipping plugin entry with no name."
}
return $false
}
if (-not $Plugin.Enabled) {
if (-not $Plugin.enabled) {
if ($WriteLogs) {
Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.Name)' (disabled)."
Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.name)' (disabled)."
}
return $false
}
if (Test-IsPublishPlugin -Plugin $Plugin) {
$allowedBranches = Get-PluginBranches -Plugin $Plugin
if ($allowedBranches.Count -eq 0) {
if ($WriteLogs) {
Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.Name)' because no publish branches are configured."
}
return $false
}
if (-not ($allowedBranches -contains $SharedSettings.CurrentBranch)) {
if ($WriteLogs) {
Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.Name)' on branch '$($SharedSettings.CurrentBranch)'."
}
return $false
}
}
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -PluginsDirectory $PluginsDirectory
if (-not (Test-Path $pluginModulePath -PathType Leaf)) {
if ($WriteLogs) {
@ -320,8 +323,8 @@ function New-PluginInvocationSettings {
$properties[$property.Name] = $property.Value
}
# Plugins receive their own config plus a shared Context object that carries runtime artifacts.
$properties['Context'] = $SharedSettings
# Plugins receive their own config plus shared runtime context.
$properties['context'] = $SharedSettings
return [pscustomobject]$properties
}
@ -344,8 +347,13 @@ function Invoke-ConfiguredPlugin {
return
}
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
}
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -PluginsDirectory $PluginsDirectory
Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.Name)'..."
Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..."
try {
$moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop
@ -355,14 +363,14 @@ function Invoke-ConfiguredPlugin {
$pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings
& $invokeCommand -Settings $pluginSettings
Write-Log -Level "OK" -Message " Plugin '$($Plugin.Name)' completed."
Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed."
}
catch {
Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.Name)' failed: $($_.Exception.Message)"
Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.name)' failed: $($_.Exception.Message)"
if (-not $ContinueOnError) {
exit 1
}
}
}
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStage, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin
Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin

View File

@ -0,0 +1,36 @@
# Release-Package
Plugin-driven release engine. Run `Release-Package.ps1` from this directory (or `Release-Package.bat`). Configuration: `scriptsettings.json` (see `_comments` for plugin keys).
Canonical source: this folder in **maksit-repoutils**. Product repositories refresh via `Update-RepoUtils` or by copying from here.
## Modules (orchestration)
| File | Role |
|------|------|
| `Release-Package.ps1` | Loads settings, builds `New-EngineContext`, runs plugins in order. |
| `PluginSupport.psm1` | Plugin discovery, `Invoke-ConfiguredPlugin`; publish plugins honor `skipPublishPlugins` from `ReleasePublishGuard` (no per-plugin `branches` on Docker/Helm/GitHub/NuGet). |
| `ReleaseContext.psm1` | Resolves semver via `Resolve-DotNetReleaseVersion` from the `DotNetReleaseVersion` plugin `projectFiles` (first `.csproj` `<Version>`). |
| `EngineSupport.psm1` | Warn-only dirty-tree preflight; default `context.tag` = `v{version}`; `Initialize-ReleaseStageContext` sets `releaseDir` only. |
## Plugins
`CorePlugins/` — e.g. `DotNetReleaseVersion`, `DockerPush`, `HelmPush`, `ReleasePublishGuard`. Optional `CustomPlugins/`.
`DotNetPack` and `QualityGate` (when used) can declare their own `projectFiles`; semver still comes only from `DotNetReleaseVersion.projectFiles`.
## `ReleasePublishGuard`
Configure this plugin **immediately before** `DockerPush`, `HelmPush`, `GitHub`, and `DotNetNuGet`. It sets shared `skipPublishPlugins` when branch/tag rules fail (`whenRequirementsNotMet`: `skip` or `fail`). Those publish plugins no longer use their own `branches` key — list allowed branches on the guard only. Preflight does not read git tags; the guard sets `context.tag` from `HEAD` when `requireExactTagOnHead` is true. **`context.version` always stays from `DotNetReleaseVersion`** (the guard does not override it).
## `DotNetTest` and shared context
`DotNetTest` runs once and writes aggregated coverage and test metrics on the shared engine context (`qualityLineCoverage`, `coverageLineRate`, `testResult`, …). `QualityGate` reads those values for optional line-coverage thresholds; it does not re-run tests. Set `scanVulnerabilities` to false to skip `dotnet list package --vulnerable`.
## Helm charts in git
Commit `Chart.yaml` with placeholder `version` and `appVersion` (for example `0.0.0`) so `helm lint` stays valid. `HelmPush` temporarily replaces both with the release semver (same as the git tag / `DotNetReleaseVersion`) before packaging, then restores the file. Image tags for `DockerPush` come from the engine context, not from the chart file in the repo.
This repository uses `src/helm/`. For a minimal scaffold chart, see **maksit-repoutils** `charts/my-service/`.

View File

@ -37,15 +37,15 @@
.CONFIGURATION
All settings are stored in scriptsettings.json:
- Plugins: Ordered plugin definitions and plugin-specific settings
- plugins: Ordered plugin definitions and plugin-specific settings
.NOTES
Plugin-specific behavior belongs in the plugin modules, not in this engine.
#>
# No parameters - behavior is controlled by configured plugin metadata:
# - non-release branches -> Run only the plugins allowed for those branches
# - release branches -> Require a matching tag and allow release-stage plugins
# - ReleasePublishGuard (before Docker/Helm/GitHub/NuGet): optional branches, tag on HEAD, remote tag; sets skipPublishPlugins.
# - Publish plugins do not use per-plugin "branches"; centralize allowed branches on the guard.
# Get the directory of the current script (for loading settings and relative paths)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
@ -80,14 +80,14 @@ if (-not (Test-Path $pluginSupportModulePath)) {
Import-Module $pluginSupportModulePath -Force
# Import DotNetProjectSupport module
$dotNetProjectSupportModulePath = Join-Path $scriptDir "DotNetProjectSupport.psm1"
if (-not (Test-Path $dotNetProjectSupportModulePath)) {
Write-Error "DotNetProjectSupport module not found at: $dotNetProjectSupportModulePath"
# Import ReleaseContext module (semver resolution for the engine)
$releaseContextModulePath = Join-Path $scriptDir "ReleaseContext.psm1"
if (-not (Test-Path $releaseContextModulePath)) {
Write-Error "ReleaseContext module not found at: $releaseContextModulePath"
exit 1
}
Import-Module $dotNetProjectSupportModulePath -Force
Import-Module $releaseContextModulePath -Force
# Import EngineSupport module
$engineSupportModulePath = Join-Path $scriptDir "EngineSupport.psm1"
@ -123,7 +123,7 @@ Write-Log -Level "STEP" -Message "==============================================
#region Preflight
$plugins = $configuredPlugins
$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -UtilsDir $utilsDir
$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -UtilsDir $utilsDir -Settings $settings
Write-Log -Level "OK" -Message "All pre-flight checks passed!"
$sharedPluginSettings = $engineContext
@ -139,31 +139,34 @@ if ($plugins.Count -eq 0) {
else {
for ($pluginIndex = 0; $pluginIndex -lt $plugins.Count; $pluginIndex++) {
$plugin = $plugins[$pluginIndex]
$pluginStage = Get-PluginStage -Plugin $plugin
$pluginStageLabel = Get-PluginStageLabel -Plugin $plugin
if ((Test-IsPublishPlugin -Plugin $plugin) -and -not $releaseStageInitialized) {
if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -PluginsDirectory $pluginsDir -WriteLogs:$false) {
$remainingPlugins = @($plugins[$pluginIndex..($plugins.Count - 1)])
Initialize-ReleaseStageContext -RemainingPlugins $remainingPlugins -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.ArtifactsDirectory -Version $engineContext.Version
Initialize-ReleaseStageContext -RemainingPlugins $remainingPlugins -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.artifactsDirectory -Version $engineContext.version
$releaseStageInitialized = $true
}
}
$continueOnError = $pluginStage -eq "Release"
$continueOnError = $pluginStageLabel -eq "release"
Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -PluginsDirectory $pluginsDir -ContinueOnError:$continueOnError
}
}
if (-not $releaseStageInitialized) {
$noReleasePluginsLogLevel = if ($engineContext.IsNonReleaseBranch) { "INFO" } else { "WARN" }
Write-Log -Level $noReleasePluginsLogLevel -Message "No release plugins executed for branch '$($engineContext.CurrentBranch)'."
$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)."
}
#endregion
#region Summary
Write-Log -Level "OK" -Message "=================================================="
if ($engineContext.IsNonReleaseBranch) {
if ($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 {
@ -171,13 +174,12 @@ else {
}
Write-Log -Level "OK" -Message "=================================================="
Write-Log -Level "INFO" -Message "Artifacts location: $($engineContext.ArtifactsDirectory)"
if ($engineContext.IsNonReleaseBranch) {
if ($engineContext.isNonReleaseBranch -and -not ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' -and $engineContext.skipPublishPlugins)) {
$preferredReleaseBranch = Get-PreferredReleaseBranch -EngineContext $engineContext
Write-Log -Level "INFO" -Message "To execute release-stage plugins, rerun from an allowed release branch such as '$preferredReleaseBranch'."
Write-Log -Level "INFO" -Message "For publish, use an allowed branch (see ReleasePublishGuard.branches), e.g. '$preferredReleaseBranch', and satisfy the guard requirements."
}
#endregion
#endregion

View File

@ -0,0 +1,145 @@
#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Helpers to resolve release semver from plugin configuration.
.DESCRIPTION
Used by New-EngineContext and the DotNetReleaseVersion plugin:
- Source: DotNetReleaseVersion plugin -> projectFiles
- Version from first path in projectFiles (SDK-style .csproj <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 <Version>)."
exit 1
}
$projectVersions = Get-CsprojVersions -ProjectFiles $projectFiles
$version = $projectVersions[$projectFiles[0]]
return [pscustomobject]@{
version = $version
}
}
Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion

View File

@ -1,92 +1,108 @@
{
"$schema": "https://json-schema.org/draft-07/schema",
"title": "Release Package Script Settings",
"description": "Configuration file for Release-Package.ps1 script.",
"Plugins": [
"description": "maksit-certs-ui: container images (Web API + Web UI) and Helm chart to OCI. No NuGet packages or zip archives. ReleasePublishGuard (before Docker/Helm) controls allowed branches and tag rules; publish plugins omit branches. Engine aligned with maksit-repoutils.",
"plugins": [
{
"Name": "DotNetTest",
"Stage": "Test",
"Enabled": true,
"project": "..\\..\\src\\MaksIT.Core.Tests",
"name": "DotNetReleaseVersion",
"stageLabel": "build",
"enabled": true,
"projectFiles": [
"..\\..\\src\\MaksIT.Webapi\\MaksIT.Webapi.csproj"
]
},
{
"name": "DotNetTest",
"stageLabel": "test",
"enabled": true,
"projects": [
"..\\..\\src\\LetsEncrypt.Tests",
"..\\..\\src\\MaksIT.Webapi.Tests"
],
"resultsDir": "..\\..\\testResults"
},
{
"Name": "QualityGate",
"Stage": "QualityGate",
"Enabled": true,
"name": "QualityGate",
"stageLabel": "qualityGate",
"enabled": true,
"coverageThreshold": 0,
"failOnVulnerabilities": true
"scanVulnerabilities": false
},
{
"Name": "DotNetPack",
"Stage": "Build",
"Enabled": true,
"projectFiles": [
"..\\..\\src\\MaksIT.Core\\MaksIT.Core.csproj"
],
"artifactsDir": "..\\..\\release"
},
{
"Name": "CreateArchive",
"Stage": "Build",
"Enabled": true,
"zipNamePattern": "maksit.core-{version}.zip"
},
{
"Name": "GitHub",
"Stage": "Release",
"Enabled": true,
"name": "ReleasePublishGuard",
"stageLabel": "release",
"enabled": true,
"branches": [
"main"
],
"githubToken": "GITHUB_MAKS_IT_COM",
"repository": "https://github.com/MAKS-IT-COM/maksit-core",
"releaseNotesFile": "..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}"
"requireExactTagOnHead": true,
"tagVersionMustMatchDotNetRelease": true,
"whenRequirementsNotMet": "skip",
"requireCleanWorkingTree": false,
"ensureTagOnRemote": true,
"remoteName": "origin"
},
{
"Name": "NuGet",
"Stage": "Release",
"Enabled": true,
"branches": [
"main"
],
"nugetApiKey": "NUGET_MAKS_IT",
"source": "https://api.nuget.org/v3/index.json"
},
{
"Name": "CleanupArtifacts",
"Stage": "Release",
"Enabled": true,
"includePatterns": [
"*"
],
"excludePatterns": [
"*.zip"
"name": "DockerPush",
"stageLabel": "release",
"enabled": true,
"registryUrl": "cr.maks-it.com",
"credentialsEnvVar": "CR_MAKS_IT",
"projectName": "certs-ui",
"contextPath": "..\\..\\src",
"pushLatest": true,
"images": [
{
"service": "server",
"dockerfile": "MaksIT.Webapi/Dockerfile"
},
{
"service": "client",
"dockerfile": "MaksIT.WebUI/Dockerfile.prod"
},
{
"service": "reverseproxy",
"dockerfile": "ReverseProxy/Dockerfile"
}
]
},
{
"name": "HelmPush",
"stageLabel": "release",
"enabled": true,
"chartPath": "..\\..\\src\\helm",
"ociRepository": "oci://cr.maks-it.com/charts",
"credentialsEnvVar": "CR_MAKS_IT",
"pushLatest": false
}
],
"_comments": {
"Plugins": {
"Name": "Plugin module file name in CorePlugins (for example, DotNetPack -> CorePlugins/DotNetPack.psm1).",
"Stage": "Execution phase. Supported values are Test, QualityGate, Build, and Release.",
"Enabled": "If true, the plugin is imported and Invoke-Plugin is called in the configured order.",
"branches": "Used only by publish plugins such as GitHub and NuGet. When the current branch is not listed, publishing is skipped.",
"project": "DotNetTest plugin only. Path to the test project directory, relative to the script folder.",
"resultsDir": "DotNetTest plugin only. Optional results directory path, relative to the script folder.",
"projectFiles": "DotNetPack, DotNetPublish, or another producer plugin can define the project files used for version discovery and artifact creation.",
"artifactsDir": "DotNetPack, DotNetPublish, or another producer plugin can define the artifacts output directory, relative to the script folder.",
"coverageThreshold": "QualityGate plugin only. Coverage threshold percent (0 disables threshold check).",
"failOnVulnerabilities": "QualityGate plugin only. If true, fail when vulnerable packages are detected.",
"githubToken": "GitHub plugin only. Environment variable name containing the GitHub token used by gh CLI.",
"repository": "GitHub plugin only. Optional owner/repo or GitHub remote URL. Leave empty to use remote.origin.url.",
"releaseNotesFile": "GitHub plugin (or another notes consumer plugin) can define the release notes source file, relative to the script folder.",
"releaseTitlePattern": "GitHub plugin only. Release title pattern. Supports {version} placeholder.",
"zipNamePattern": "CreateArchive plugin only. Archive name pattern for packaged release assets. Supports {version} placeholder.",
"nugetApiKey": "NuGet plugin only. Environment variable name containing the NuGet API key.",
"source": "NuGet plugin only. Feed URL passed to dotnet nuget push.",
"includePatterns": "CleanupArtifacts plugin only. File patterns to remove from artifactsDir (for example ['*.nupkg','*.snupkg']).",
"excludePatterns": "CleanupArtifacts plugin only. File patterns to keep even when includePatterns match (for example ['*.zip'])."
"plugins": {
"name": "Plugin module file name in CorePlugins (for example, DockerPush -> CorePlugins/DockerPush.psm1).",
"stageLabel": "Execution phase: test, qualityGate, build, or release (lowercase). continueOnError is true only for release.",
"enabled": "If true, the plugin is imported and Invoke-Plugin is called in the configured order.",
"DotNetReleaseVersion": "Reads <Version> from the first projectFiles entry; ReleasePublishGuard checks tag on HEAD matches when tagVersionMustMatchDotNetRelease is true.",
"projects": "DotNetTest: array of test project paths relative to Release-Package. Requires utils/TestRunner.psm1 (multi-project Cobertura aggregation).",
"resultsDir": "DotNetTest: optional; when multiple projects are listed, TestRunner uses Release-Package/TestResults if omitted.",
"projectFiles": "QualityGate: add .csproj paths when scanVulnerabilities is true (dotnet list package --vulnerable).",
"scanVulnerabilities": "QualityGate: false = no dotnet list; true requires projectFiles. failOnVulnerabilities optional when scan is true (default true).",
"coverageThreshold": "QualityGate: >0 requires line % on shared context (qualityLineCoverage, coverageLineRate, or testResult.LineRate). 0 disables.",
"registryUrl": "DockerPush: registry host without scheme.",
"credentialsEnvVar": "DockerPush / HelmPush: env var with base64(username:password).",
"projectName": "DockerPush: image path segment: registryUrl/projectName/service:tag.",
"contextPath": "DockerPush: directory containing MaksIT.Webapi and MaksIT.WebUI (repo src/).",
"images": "Dockerfile paths are relative to contextPath.",
"chartPath": "HelmPush: directory containing Chart.yaml. Keep version/appVersion as placeholders in git (e.g. 0.0.0); HelmPush overwrites with bare semver from DotNetReleaseVersion (shared.version, e.g. 3.3.4, no v) before package/push; falls back to stripping v from shared.tag if version is missing.",
"ociRepository": "HelmPush: OCI registry URL for helm push (e.g. oci://cr.maks-it.com/charts).",
"pushLatest": "Docker: also push :latest (images also get bare :3.3.4 and :v3.3.4 from DotNetReleaseVersion). Helm: oras copy chart to :latest (requires oras on PATH).",
"branches": "ReleasePublishGuard: allowed branches for publish (omit or [\"*\"] = any). Do not put branches on DockerPush/HelmPush/GitHub/DotNetNuGet.",
"requireExactTagOnHead": "ReleasePublishGuard: require git describe --tags --exact-match HEAD (vX.Y.Z).",
"tagVersionMustMatchDotNetRelease": "ReleasePublishGuard: tag semver must equal DotNetReleaseVersion when true.",
"whenRequirementsNotMet": "ReleasePublishGuard: skip (suppress publish plugins) or fail (exit 1).",
"requireCleanWorkingTree": "ReleasePublishGuard: block publish if git status is not clean.",
"ensureTagOnRemote": "ReleasePublishGuard: push tag to remoteName if missing.",
"remoteName": "ReleasePublishGuard: git remote for tag push (default origin).",
"maksit-repoutils": "Full engine and plugin docs: https://github.com/MAKS-IT-COM/maksit-repoutils (src/Release-Package/README.md)."
}
}
}

View File

@ -51,7 +51,8 @@ function Invoke-TestsWithCoverage {
Runs unit tests with code coverage and returns coverage metrics.
.PARAMETER TestProjectPath
Path to the test project directory.
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).
@ -71,15 +72,20 @@ function Invoke-TestsWithCoverage {
- MethodRate: double
- TotalMethods: int
- CoveredMethods: int
- CoverageFile: string
- 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,
[string[]]$TestProjectPath,
[switch]$Silent,
@ -90,96 +96,172 @@ function Invoke-TestsWithCoverage {
$ErrorActionPreference = "Stop"
# Resolve path
$TestProjectDir = Resolve-Path $TestProjectPath -ErrorAction SilentlyContinue
if (-not $TestProjectDir) {
# Normalize to a non-empty list of absolute working directories (folder containing the test project).
$resolvedProjectDirs = [System.Collections.Generic.List[string]]::new()
foreach ($raw in $TestProjectPath) {
if ([string]::IsNullOrWhiteSpace($raw)) { continue }
$full = [System.IO.Path]::GetFullPath($raw.Trim())
if (-not (Test-Path $full)) {
return [PSCustomObject]@{
Success = $false
Error = "Test project not found at: $raw"
}
}
$item = Get-Item -LiteralPath $full
$dir = if ($item.PSIsContainer) { $item.FullName } else { $item.Directory.FullName }
if ($resolvedProjectDirs -notcontains $dir) {
[void]$resolvedProjectDirs.Add($dir)
}
}
if ($resolvedProjectDirs.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
Error = "Test project not found at: $TestProjectPath"
Error = "No valid test project paths were provided."
}
}
if ([string]::IsNullOrWhiteSpace($ResultsDirectory)) {
$ResultsDir = Join-Path $TestProjectDir "TestResults"
$ResultsDir = Join-Path $resolvedProjectDirs[0] "TestResults"
}
else {
$ResultsDir = [System.IO.Path]::GetFullPath($ResultsDirectory)
}
# Clean previous results
# 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..."
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $TestProjectDir"
foreach ($d in $resolvedProjectDirs) {
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
}
}
# Run tests with coverage collection
Push-Location $TestProjectDir
try {
$dotnetArgs = @(
"test"
"--collect:XPlat Code Coverage"
"--results-directory", $ResultsDir
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
)
if ($Silent) {
$null = & dotnet @dotnetArgs 2>&1
} else {
& dotnet @dotnetArgs
}
foreach ($TestProjectDir in $resolvedProjectDirs) {
Push-Location $TestProjectDir
try {
$dotnetArgs = @(
"test"
"--collect:XPlat Code Coverage"
"--results-directory", $ResultsDir
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
)
$testExitCode = $LASTEXITCODE
if ($testExitCode -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "Tests failed with exit code $testExitCode"
if ($Silent) {
$null = & dotnet @dotnetArgs 2>&1
}
else {
& dotnet @dotnetArgs
}
$testExitCode = $LASTEXITCODE
if ($testExitCode -ne 0) {
return [PSCustomObject]@{
Success = $false
Error = "Tests failed in '$TestProjectDir' with exit code $testExitCode"
}
}
}
}
finally {
Pop-Location
finally {
Pop-Location
}
}
# Find the coverage file
$CoverageFile = Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Select-Object -First 1
$coverageFiles = @(Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Sort-Object FullName)
if (-not $CoverageFile) {
if ($coverageFiles.Count -eq 0) {
return [PSCustomObject]@{
Success = $false
Error = "Coverage file not found"
Error = "Coverage file not found under: $ResultsDir"
}
}
if (-not $Silent) {
Write-TestRunnerLogInternal -Level "OK" -Message "Coverage file found: $($CoverageFile.FullName)"
foreach ($cf in $coverageFiles) {
Write-TestRunnerLogInternal -Level "OK" -Message "Coverage file found: $($cf.FullName)"
}
Write-TestRunnerLogInternal -Level "STEP" -Message "Parsing coverage data..."
}
# Parse coverage data from Cobertura XML
[xml]$coverageXml = Get-Content $CoverageFile.FullName
$lineRate = [math]::Round([double]$coverageXml.coverage.'line-rate' * 100, 1)
$branchRate = [math]::Round([double]$coverageXml.coverage.'branch-rate' * 100, 1)
# Calculate method coverage from packages
# Aggregate line/branch from Cobertura counters; methods by walking all files.
$linesCoveredTotal = 0L
$linesValidTotal = 0L
$branchesCoveredTotal = 0L
$branchesValidTotal = 0L
$totalMethods = 0
$coveredMethods = 0
foreach ($package in $coverageXml.coverage.packages.package) {
foreach ($class in $package.classes.class) {
foreach ($method in $class.methods.method) {
$totalMethods++
if ([double]$method.'line-rate' -gt 0) {
$coveredMethods++
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) {
@ -195,7 +277,9 @@ function Invoke-TestsWithCoverage {
MethodRate = $methodRate
TotalMethods = $totalMethods
CoveredMethods = $coveredMethods
CoverageFile = $CoverageFile.FullName
CoverageFile = $coverageFileReportArg
CoverageFiles = $coveragePaths
ResultsDirectory = $resultsDirectoryFull
}
}