mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-06-10 00:28:11 +02:00
(bugfix): improved build pipeline, more generic helm chart, minor bug fixing, tests
This commit is contained in:
parent
df835155ba
commit
0f4c4cbeac
3
.gitignore
vendored
3
.gitignore
vendored
@ -264,4 +264,5 @@ __pycache__/
|
||||
|
||||
# SonarQube
|
||||
.scannerwork/
|
||||
.sonarlint/
|
||||
.sonarlint/
|
||||
src/MaksIT.WebUI/public/pdf.worker.min.mjs
|
||||
|
||||
74
CHANGELOG.md
Normal file
74
CHANGELOG.md
Normal 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
53
CONTRIBUTING.md
Normal 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
153
README.md
@ -1,5 +1,7 @@
|
||||
# MaksIT.CertsUI – Modern container-native ACME client with a full WebUI experience
|
||||
|
||||
  
|
||||
|
||||
MaksIT.CertsUI is a powerful, container-native ACMEv2 client built to simplify and automate the entire lifecycle of HTTPS certificates issued by Let’s Encrypt. It is an independent, unofficial project and is not affiliated with or endorsed by Let’s 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 [Let’s 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 chart’s 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 chart’s 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 platform’s 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
21
assets/badges/coverage-branches.svg
Normal file
21
assets/badges/coverage-branches.svg
Normal 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 |
21
assets/badges/coverage-lines.svg
Normal file
21
assets/badges/coverage-lines.svg
Normal 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 |
21
assets/badges/coverage-methods.svg
Normal file
21
assets/badges/coverage-methods.svg
Normal 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 |
@ -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
|
||||
@ -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
|
||||
13
src/LetsEncrypt.Tests/ContentTypeTests.cs
Normal file
13
src/LetsEncrypt.Tests/ContentTypeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
28
src/LetsEncrypt.Tests/LetsEncrypt.Tests.csproj
Normal file
28
src/LetsEncrypt.Tests/LetsEncrypt.Tests.csproj
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
295
src/MaksIT.WebUI/package-lock.json
generated
295
src/MaksIT.WebUI/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
42
src/MaksIT.Webapi.Tests/Domain/SettingsDomainTests.cs
Normal file
42
src/MaksIT.Webapi.Tests/Domain/SettingsDomainTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
70
src/MaksIT.Webapi.Tests/Infrastructure/WebApiTestFixture.cs
Normal file
70
src/MaksIT.Webapi.Tests/Infrastructure/WebApiTestFixture.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/MaksIT.Webapi.Tests/MaksIT.Webapi.Tests.csproj
Normal file
29
src/MaksIT.Webapi.Tests/MaksIT.Webapi.Tests.csproj
Normal 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>
|
||||
134
src/MaksIT.Webapi.Tests/Services/AccountServiceTests.cs
Normal file
134
src/MaksIT.Webapi.Tests/Services/AccountServiceTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
45
src/MaksIT.Webapi.Tests/Services/AgentServiceTests.cs
Normal file
45
src/MaksIT.Webapi.Tests/Services/AgentServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
104
src/MaksIT.Webapi.Tests/Services/CacheServiceTests.cs
Normal file
104
src/MaksIT.Webapi.Tests/Services/CacheServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
364
src/MaksIT.Webapi.Tests/Services/CertsFlowServiceTests.cs
Normal file
364
src/MaksIT.Webapi.Tests/Services/CertsFlowServiceTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
137
src/MaksIT.Webapi.Tests/Services/IdentityServiceTests.cs
Normal file
137
src/MaksIT.Webapi.Tests/Services/IdentityServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
43
src/MaksIT.Webapi.Tests/Services/SettingsServiceTests.cs
Normal file
43
src/MaksIT.Webapi.Tests/Services/SettingsServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
124
src/Release.ps1
124
src/Release.ps1
@ -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."
|
||||
@ -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
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 -}}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
175
utils/Release-Package/CorePlugins/DockerPush.psm1
Normal file
175
utils/Release-Package/CorePlugins/DockerPush.psm1
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
40
utils/Release-Package/CorePlugins/DotNetReleaseVersion.psm1
Normal file
40
utils/Release-Package/CorePlugins/DotNetReleaseVersion.psm1
Normal 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
|
||||
|
||||
|
||||
@ -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)%"
|
||||
|
||||
@ -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) {
|
||||
|
||||
180
utils/Release-Package/CorePlugins/HelmPush.psm1
Normal file
180
utils/Release-Package/CorePlugins/HelmPush.psm1
Normal 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
|
||||
@ -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) {
|
||||
|
||||
161
utils/Release-Package/CorePlugins/ReleasePublishGuard.psm1
Normal file
161
utils/Release-Package/CorePlugins/ReleasePublishGuard.psm1
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
36
utils/Release-Package/README.md
Normal file
36
utils/Release-Package/README.md
Normal 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/`.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
145
utils/Release-Package/ReleaseContext.psm1
Normal file
145
utils/Release-Package/ReleaseContext.psm1
Normal 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
|
||||
|
||||
|
||||
|
||||
@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user