diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b85612..8cfd60b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ 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). +## [0.3.4] - 2026-07-06 + +### Added + +- **OAuth / external identity providers** (extracted from FiPlan.Anywhere): + - `@maks-it.com/webui-contracts`: `LoginProviderExternal`, `EmailProtocol`, `ServerAuthenticationMethod`, `MsalViewModel`, `IdpRedeemLoginResponse`, `MailboxOAuthPending`, and mailbox OAuth session/query constants. + - `@maks-it.com/webui-core`: `startIdpRedirect`, `shouldSkipIdpLoginCallback`, `useIdpLoginCallback`, mailbox OAuth redirect helpers (`collectMailboxOAuthProtocols`, `startMailboxOAuthRedirect`, `useMailboxOAuthReturn`), and `removeParamFromUrl` / `removeParamsFromUrl`. + - `@maks-it.com/webui-components`: `ExternalLoginButtons`, `IdpLoginCallbackHandler`, `MailboxOAuthReturnHandler`. +- `@maks-it.com/webui-core`: `HOSTNAMES_ARRAY_POLICY` for Certs UI account hostname PATCH collections (`identityKey` / `idFieldKey`: `hostname`). +- `deepDelta` regression tests for Certs/Vault consumer forms — `HOSTNAMES_ARRAY_POLICY`, `ENTITY_SCOPES_ARRAY_POLICY`, `VERSIONS_ARRAY_POLICY`, advanced `ArrayPolicy` options (`rootKey`, `childArrayKeys`, `deleteItemWhenRoleRemoved`). + +### Changed + +- All `@maks-it.com/webui-*` packages versioned at **0.3.4** with aligned workspace dependency ranges. +- RepoUtils entry points and docs: `utils/src/` → `utils/` (`Invoke-TestEngine.bat`, `Invoke-ReleasePackage-Single.bat`, `Update-RepoUtils.bat`; README and `assets/docs/NPM_PUBLISH.md` updated). +- Release secret env vars documented as logical names **`Npm`** and **`GitHub`** (see `utils/engines/release/scriptSettings.json`). +- Package READMEs document OAuth exports and PATCH collection policies. + +### Fixed + +- `deepDelta` identifiable-array diff: role-null removals emit the correct identity field (`id` vs `_deltaId`); item deltas are omitted when only `operations` changed (no spurious id-only placeholders). + ## [0.3.3] - 2026-05-31 ### Changed diff --git a/README.md b/README.md index 1fc114a..6760069 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Shared React UI library for **maksit-certs-ui** and **maksit-vault** WebUI apps. | `@maks-it.com/webui-core` | Utilities (`deepDelta`, enum helpers, ACL parsers) and `useFormState` | | `@maks-it.com/webui-components` | React components, layout, editors, DataTable, auth shell | -Source lives under `src/` (npm workspaces). Release automation lives under `utils/src/` (from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils)). +Source lives under `src/` (npm workspaces). Release automation lives under `utils/` (from [maksit-repoutils](https://github.com/MAKS-IT-COM/maksit-repoutils)). ## Local development @@ -26,25 +26,25 @@ npm run storybook **Storybook** (`npm run storybook`) runs a local catalog of `@maks-it.com/webui-components` with Tailwind, React Router, autodocs, a11y checks, and **Vitest component tests** (testing widget + `npm run test-storybook`). Stories live under `src/stories/components/` (mirroring component folders); see `src/stories/README.md` for story conventions and testing. -Tests and coverage badges: **`utils/src/Invoke-TestEngine.bat`** (plugin config in `utils/src/engines/test/scriptSettings.json`; uses `NpmJestTest`). +Tests and coverage badges: **`utils\Invoke-TestEngine.bat`** (plugin config in `utils\engines\test\scriptSettings.json`; uses `NpmJestTest`). ## Release to npmjs -1. Set **`NPMJS_MAKS_IT`** to your npm automation token (same pattern as `NUGET_MAKS_IT` for NuGet). +1. Set **`Npm`** environment variable to your npm automation token (logical secret name in `scriptSettings.json`). 2. Bump **`src/package.json`** `version` (and tag `vX.Y.Z` on `main` when using the publish guard). -3. Run **`utils/src/Invoke-ReleasePackage.bat`** (or `pwsh utils/src/engines/release/Invoke-ReleasePackage.ps1`). +3. Run **`utils\Invoke-ReleasePackage-Single.bat`** (or `pwsh utils\engines\release\Invoke-ReleasePackage.ps1`). -Configured plugins (see `utils/src/engines/release/scriptSettings.json`): +Configured plugins (see `utils\engines\release\scriptSettings.json`): | Plugin | Role | |--------|------| | `NpmReleaseVersion` | Read semver from `src/package.json`; sync `packages/*/package.json` | | `NpmBuild` | `npm ci` + `npm run build` | | `ReleasePublishGuard` | Branch/tag checks before publish | -| `GitHub` | GitHub release (optional; needs `GITHUB_MAKS_IT_COM`) | +| `GitHub` | GitHub release (optional; set `GitHub` env var) | | `NpmPublish` | Publish workspace packages in dependency order | -Refresh shared utils from repoutils: **`utils/src/Update-RepoUtils.bat`**. +Refresh shared utils from repoutils: **`utils\Update-RepoUtils.bat`**. ## Consume in product repos diff --git a/assets/badges/coverage-branches.svg b/assets/badges/coverage-branches.svg index 0d2ffe0..4071bcf 100644 --- a/assets/badges/coverage-branches.svg +++ b/assets/badges/coverage-branches.svg @@ -1,5 +1,5 @@ - - Branch Coverage: 72% + + Branch Coverage: 78% @@ -15,7 +15,7 @@ Branch Coverage - - 72% + + 78% diff --git a/assets/badges/coverage-lines.svg b/assets/badges/coverage-lines.svg index b8bfce8..c27ff27 100644 --- a/assets/badges/coverage-lines.svg +++ b/assets/badges/coverage-lines.svg @@ -1,5 +1,5 @@ - - Line Coverage: 75.2% + + Line Coverage: 82.2% @@ -9,13 +9,13 @@ - + Line Coverage - - 75.2% + + 82.2% diff --git a/assets/badges/coverage-methods.svg b/assets/badges/coverage-methods.svg index 8a48dd4..974d7ed 100644 --- a/assets/badges/coverage-methods.svg +++ b/assets/badges/coverage-methods.svg @@ -1,5 +1,5 @@ - - Method Coverage: 66.7% + + Method Coverage: 62.4% @@ -15,7 +15,7 @@ Method Coverage - - 66.7% + + 62.4% diff --git a/assets/docs/NPM_PUBLISH.md b/assets/docs/NPM_PUBLISH.md index a0ae1fa..1a4d904 100644 --- a/assets/docs/NPM_PUBLISH.md +++ b/assets/docs/NPM_PUBLISH.md @@ -17,7 +17,7 @@ Published packages: 3. Create an **Automation** token (recommended) or Granular Access token with **Publish** on `@maks-it.com/*`: - https://www.npmjs.com/settings/maks-it.com/tokens 4. Store the token for release tooling: - - **CI / Release-Package:** set env var `NPMJS_MAKS_IT` to the token value (same pattern as `NUGET_MAKS_IT`). + - **CI / release engine:** set environment variable **`Npm`** (logical secret name in `scriptSettings.json`). - **Local one-off publish:** `npm login` or a user-level `~/.npmrc` entry: ``` //registry.npmjs.org/:_authToken=YOUR_TOKEN @@ -50,13 +50,13 @@ npm view @maks-it.com/webui-components version ## Release pipeline (recommended) -Use **`utils/src/Invoke-ReleasePackage.bat`** (or `pwsh utils/src/engines/release/Invoke-ReleasePackage.ps1`): +Use **`utils\Invoke-ReleasePackage-Single.bat`** (or `pwsh utils\engines\release\Invoke-ReleasePackage.ps1`): 1. Bump version in `src/package.json` (or tag drives `NpmReleaseVersion`). 2. Tag `HEAD` with exact semver, e.g. `git tag v0.2.0 && git push origin v0.2.0`. -3. Set `NPMJS_MAKS_IT` and run the release engine. +3. Set `$env:Npm` and run the release engine. -`utils/src/engines/release/scriptSettings.json` runs `NpmReleaseVersion`, `NpmBuild`, `ReleasePublishGuard`, optional `GitHub`, then `NpmPublish` in dependency order. +`utils\engines\release\scriptSettings.json` runs `NpmReleaseVersion`, `NpmBuild`, `ReleasePublishGuard`, optional `GitHub`, then `NpmPublish` in dependency order. ## After publish — Certs UI / Vault diff --git a/src/coverage/coverage-summary.json b/src/coverage/coverage-summary.json index 1ef1a18..8bf01c3 100644 --- a/src/coverage/coverage-summary.json +++ b/src/coverage/coverage-summary.json @@ -1,20 +1,26 @@ -{"total": {"lines":{"total":481,"covered":362,"skipped":0,"pct":75.25},"statements":{"total":514,"covered":389,"skipped":0,"pct":75.68},"functions":{"total":66,"covered":44,"skipped":0,"pct":66.66},"branches":{"total":279,"covered":201,"skipped":0,"pct":72.04},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} +{"total": {"lines":{"total":535,"covered":440,"skipped":0,"pct":82.24},"statements":{"total":572,"covered":471,"skipped":0,"pct":82.34},"functions":{"total":85,"covered":53,"skipped":0,"pct":62.35},"branches":{"total":309,"covered":241,"skipped":0,"pct":77.99},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\PagedRequest.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\PatchOperation.ts": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\PatchRequestModelBase.ts": {"lines":{"total":4,"covered":4,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\RequestModelBase.ts": {"lines":{"total":2,"covered":2,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":2,"covered":2,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\index.ts": {"lines":{"total":10,"covered":10,"skipped":0,"pct":100},"functions":{"total":9,"covered":2,"skipped":0,"pct":22.22},"statements":{"total":17,"covered":17,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\index.ts": {"lines":{"total":11,"covered":11,"skipped":0,"pct":100},"functions":{"total":9,"covered":2,"skipped":0,"pct":22.22},"statements":{"total":18,"covered":18,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\identity\\Claims.ts": {"lines":{"total":56,"covered":56,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":56,"covered":56,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\identity\\login\\LoginRequest.ts": {"lines":{"total":9,"covered":9,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":9,"covered":9,"skipped":0,"pct":100},"branches":{"total":8,"covered":8,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\identity\\login\\RefreshTokenRequest.ts": {"lines":{"total":2,"covered":2,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":2,"covered":2,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\identity\\logout\\LogoutRequest.ts": {"lines":{"total":2,"covered":2,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":2,"covered":2,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\oauth\\EmailProtocol.ts": {"lines":{"total":4,"covered":4,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} +,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\oauth\\LoginProviderExternal.ts": {"lines":{"total":11,"covered":5,"skipped":0,"pct":45.45},"functions":{"total":3,"covered":1,"skipped":0,"pct":33.33},"statements":{"total":14,"covered":7,"skipped":0,"pct":50},"branches":{"total":8,"covered":2,"skipped":0,"pct":25}} +,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\oauth\\ServerAuthenticationMethod.ts": {"lines":{"total":4,"covered":4,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} +,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\oauth\\index.ts": {"lines":{"total":11,"covered":11,"skipped":0,"pct":100},"functions":{"total":9,"covered":0,"skipped":0,"pct":0},"statements":{"total":13,"covered":13,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} +,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\contracts\\src\\oauth\\oauthConstants.ts": {"lines":{"total":4,"covered":4,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\acl\\parseAclEntry.ts": {"lines":{"total":17,"covered":17,"skipped":0,"pct":100},"functions":{"total":4,"covered":4,"skipped":0,"pct":100},"statements":{"total":18,"covered":18,"skipped":0,"pct":100},"branches":{"total":6,"covered":6,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\dataTable\\dataTableFilters.ts": {"lines":{"total":5,"covered":5,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\dataTable\\dataTablePaged.ts": {"lines":{"total":4,"covered":4,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":4,"covered":4,"skipped":0,"pct":100},"branches":{"total":16,"covered":16,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\date\\isValidDateString.ts": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":7,"covered":7,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\deepCopy.ts": {"lines":{"total":17,"covered":16,"skipped":0,"pct":94.11},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":17,"covered":16,"skipped":0,"pct":94.11},"branches":{"total":12,"covered":11,"skipped":0,"pct":91.66}} -,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\deepDelta.ts": {"lines":{"total":183,"covered":119,"skipped":0,"pct":65.02},"functions":{"total":18,"covered":14,"skipped":0,"pct":77.77},"statements":{"total":199,"covered":130,"skipped":0,"pct":65.32},"branches":{"total":137,"covered":92,"skipped":0,"pct":67.15}} +,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\deepDelta.ts": {"lines":{"total":185,"covered":153,"skipped":0,"pct":82.7},"functions":{"total":18,"covered":15,"skipped":0,"pct":83.33},"statements":{"total":200,"covered":164,"skipped":0,"pct":82},"branches":{"total":139,"covered":116,"skipped":0,"pct":83.45}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\deepEqual.ts": {"lines":{"total":36,"covered":35,"skipped":0,"pct":97.22},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":38,"covered":37,"skipped":0,"pct":97.36},"branches":{"total":26,"covered":25,"skipped":0,"pct":96.15}} +,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\deep\\patchCollectionPolicies.ts": {"lines":{"total":17,"covered":15,"skipped":0,"pct":88.23},"functions":{"total":5,"covered":5,"skipped":0,"pct":100},"statements":{"total":17,"covered":15,"skipped":0,"pct":88.23},"branches":{"total":16,"covered":10,"skipped":0,"pct":62.5}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\enum\\hasAnyFlag.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\enum\\hasFlag.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":1,"covered":0,"skipped":0,"pct":0}} ,"E:\\Users\\maksym\\source\\repos\\MaksIT\\maksit-webui\\src\\packages\\core\\src\\functions\\enum\\toggleFlag.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":3,"covered":2,"skipped":0,"pct":66.66}} diff --git a/src/package-lock.json b/src/package-lock.json index 814d4fb..91f08f1 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "maksit-webui", - "version": "0.3.3", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maksit-webui", - "version": "0.3.3", + "version": "0.3.4", "license": "MIT", "workspaces": [ "packages/*" @@ -2326,6 +2326,29 @@ "node": ">=14.0.0" } }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { "version": "11.20.0", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.20.0.tgz", @@ -3988,6 +4011,29 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -10498,10 +10544,10 @@ }, "packages/components": { "name": "@maks-it.com/webui-components", - "version": "0.3.3", + "version": "0.3.4", "dependencies": { - "@maks-it.com/webui-contracts": "^0.3.3", - "@maks-it.com/webui-core": "^0.3.3" + "@maks-it.com/webui-contracts": "^0.3.4", + "@maks-it.com/webui-core": "^0.3.4" }, "devDependencies": { "@tanstack/react-table": "^8.21.3", @@ -10529,7 +10575,7 @@ }, "packages/contracts": { "name": "@maks-it.com/webui-contracts", - "version": "0.3.3", + "version": "0.3.4", "devDependencies": { "tsup": "^8.5.1", "typescript": "^6.0.3", @@ -10541,9 +10587,9 @@ }, "packages/core": { "name": "@maks-it.com/webui-core", - "version": "0.3.3", + "version": "0.3.4", "dependencies": { - "@maks-it.com/webui-contracts": "^0.3.3", + "@maks-it.com/webui-contracts": "^0.3.4", "date-fns": "^4.3.0" }, "devDependencies": { diff --git a/src/package.json b/src/package.json index 7fa016f..eb5e98c 100644 --- a/src/package.json +++ b/src/package.json @@ -1,7 +1,7 @@ { "name": "maksit-webui", "private": true, - "version": "0.3.3", + "version": "0.3.4", "description": "Shared React UI library for MaksIT Certs UI and Vault WebUI", "workspaces": [ "packages/*" diff --git a/src/packages/components/README.md b/src/packages/components/README.md index 0d1b09a..32427a0 100644 --- a/src/packages/components/README.md +++ b/src/packages/components/README.md @@ -25,6 +25,9 @@ npm install react react-dom react-router-dom lucide-react @tanstack/react-table | `EntityScopesSummary` | Entity scope permissions summary | | `Toast`, `addToast` | Toast notifications | | `FieldContainer` | Label + validation wrapper for fields | +| `ExternalLoginButtons` | Google/Microsoft (or custom) external login buttons | +| `IdpLoginCallbackHandler` | Headless handler for `?code=` app login OAuth return | +| `MailboxOAuthReturnHandler` | Headless handler for mailbox OAuth multi-protocol return | ## Example — DataTable diff --git a/src/packages/components/package.json b/src/packages/components/package.json index bc497f9..e2fac76 100644 --- a/src/packages/components/package.json +++ b/src/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@maks-it.com/webui-components", - "version": "0.3.3", + "version": "0.3.4", "description": "Shared React components for MaksIT WebUI apps", "type": "module", "main": "./dist/index.cjs", @@ -33,8 +33,8 @@ "directory": "src/packages/components" }, "dependencies": { - "@maks-it.com/webui-contracts": "^0.3.3", - "@maks-it.com/webui-core": "^0.3.3" + "@maks-it.com/webui-contracts": "^0.3.4", + "@maks-it.com/webui-core": "^0.3.4" }, "peerDependencies": { "@tanstack/react-table": "^8.0.0", diff --git a/src/packages/components/src/index.ts b/src/packages/components/src/index.ts index 26afae2..997deda 100644 --- a/src/packages/components/src/index.ts +++ b/src/packages/components/src/index.ts @@ -30,3 +30,4 @@ export { Toast as ToastContainer } from './components/Toast' export { EntityScopesSummary } from './components/Scopes' export type { EntityScopesSummaryProps } from './components/Scopes' export { Layout } from './components/Layout' +export * from './oauth' diff --git a/src/packages/contracts/README.md b/src/packages/contracts/README.md index 3247510..f1a089f 100644 --- a/src/packages/contracts/README.md +++ b/src/packages/contracts/README.md @@ -19,6 +19,7 @@ npm install @maks-it.com/webui-contracts zod | API errors | `ProblemDetails` | | Search | `SearchResponseBase`, `SearchEntityScopeEntry` | | Identity | `LoginRequest` / `LoginRequestSchema`, `LoginResponse`, `RefreshTokenRequest`, `LogoutRequest`, `Claims` | +| OAuth / IdP | `LoginProviderExternal`, `EmailProtocol`, `ServerAuthenticationMethod`, `IdpRedeemLoginResponse`, `MailboxOAuthPending`, mailbox OAuth constants | | Misc | `TrngResponse`, `RequestModelBase`, `ResponseModelBase` | ## Example diff --git a/src/packages/contracts/package.json b/src/packages/contracts/package.json index 2c95509..bd5f01b 100644 --- a/src/packages/contracts/package.json +++ b/src/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@maks-it.com/webui-contracts", - "version": "0.3.3", + "version": "0.3.4", "description": "Shared TypeScript contracts for MaksIT WebUI apps", "type": "module", "main": "./dist/index.cjs", diff --git a/src/packages/contracts/src/index.ts b/src/packages/contracts/src/index.ts index 5f88e10..deeaa87 100644 --- a/src/packages/contracts/src/index.ts +++ b/src/packages/contracts/src/index.ts @@ -32,3 +32,5 @@ export { RefreshTokenRequestSchema } from './identity/login/RefreshTokenRequest' export type { LogoutRequest } from './identity/logout/LogoutRequest' export { LogoutRequestSchema } from './identity/logout/LogoutRequest' export type { LogoutResponse } from './identity/logout/LogoutResponse' + +export * from './oauth' diff --git a/src/packages/core/README.md b/src/packages/core/README.md index 031d0e9..56aef11 100644 --- a/src/packages/core/README.md +++ b/src/packages/core/README.md @@ -22,7 +22,9 @@ npm install @maks-it.com/webui-core @maks-it.com/webui-contracts @microsoft/sign | HTTP | `createWebUiHttpClient`, auth interceptors, Problem Details helpers | | SignalR | `useWebUiHub` | | Enum / flags | `enumToArr`, `flagsToString`, `hasFlag`, `toggleFlag` | +| PATCH collections | `ENTITY_SCOPES_ARRAY_POLICY`, `HOSTNAMES_ARRAY_POLICY`, `VERSIONS_ARRAY_POLICY` | | Identity storage | `readIdentity`, `writeIdentity`, `removeIdentity` | +| OAuth / IdP | `startIdpRedirect`, `useIdpLoginCallback`, `collectMailboxOAuthProtocols`, `startMailboxOAuthRedirect`, `useMailboxOAuthReturn` | ## Example — form state diff --git a/src/packages/core/package.json b/src/packages/core/package.json index 7501842..22bdbb4 100644 --- a/src/packages/core/package.json +++ b/src/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@maks-it.com/webui-core", - "version": "0.3.3", + "version": "0.3.4", "description": "Shared utilities and hooks for MaksIT WebUI apps", "type": "module", "main": "./dist/index.cjs", @@ -34,7 +34,7 @@ "directory": "src/packages/core" }, "dependencies": { - "@maks-it.com/webui-contracts": "^0.3.3", + "@maks-it.com/webui-contracts": "^0.3.4", "date-fns": "^4.3.0" }, "peerDependencies": { diff --git a/src/packages/core/src/functions/deep/deepDelta.ts b/src/packages/core/src/functions/deep/deepDelta.ts index 1cd2c30..65e2782 100644 --- a/src/packages/core/src/functions/deep/deepDelta.ts +++ b/src/packages/core/src/functions/deep/deepDelta.ts @@ -437,7 +437,7 @@ export const deepDelta = >( const roleBecameNull = backupRole !== null && formRole === null if (roleBecameNull) { const removeItem = {} as DeltaArrayItem - ;(removeItem as PlainObject)[idFieldKey] = fid as IdLike + ;(removeItem as PlainObject)[getIdFieldForItem(backupItem)] = fid as IdLike removeItem.operations = { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection } arrayDelta.push(removeItem) continue @@ -446,7 +446,8 @@ export const deepDelta = >( // 1.e) Field-level diff const itemDeltaBase = {} as (PlainObject & OperationBag & { id?: U['id'] }) - ;(itemDeltaBase as PlainObject)[getIdFieldForItem(formItem)] = fid as IdLike + const identityField = getIdFieldForItem(formItem) + ;(itemDeltaBase as PlainObject)[identityField] = fid as IdLike calculateDelta( formItem as PlainObject, @@ -454,7 +455,9 @@ export const deepDelta = >( itemDeltaBase ) - const hasMeaningfulChanges = Object.keys(itemDeltaBase).some(k => k !== idFieldKey) + const hasMeaningfulChanges = Object.keys(itemDeltaBase).some( + k => k !== identityField && k !== 'operations' + ) if (hasMeaningfulChanges) { arrayDelta.push(itemDeltaBase as DeltaArrayItem) } diff --git a/src/packages/core/src/functions/deep/index.ts b/src/packages/core/src/functions/deep/index.ts index cbe43f9..d35381a 100644 --- a/src/packages/core/src/functions/deep/index.ts +++ b/src/packages/core/src/functions/deep/index.ts @@ -5,6 +5,7 @@ import { } from './deepDelta' import { ENTITY_SCOPES_ARRAY_POLICY, + HOSTNAMES_ARRAY_POLICY, VERSIONS_ARRAY_POLICY, } from './patchCollectionPolicies' import { deepEqualArrays, deepEqual } from './deepEqual' @@ -17,6 +18,7 @@ export { deepDelta, deltaHasOperations, ENTITY_SCOPES_ARRAY_POLICY, + HOSTNAMES_ARRAY_POLICY, VERSIONS_ARRAY_POLICY, deepEqualArrays, deepEqual, diff --git a/src/packages/core/src/functions/deep/patchCollectionPolicies.ts b/src/packages/core/src/functions/deep/patchCollectionPolicies.ts index 3ef3d99..4231389 100644 --- a/src/packages/core/src/functions/deep/patchCollectionPolicies.ts +++ b/src/packages/core/src/functions/deep/patchCollectionPolicies.ts @@ -36,7 +36,17 @@ function entityScopeFallback(item: Record): string { } /** - * Array policy for user/API key `entityScopes` payloads passed to {@link deepDelta}. + * Array policy for account `hostnames` collections (Certs UI `EditAccount`). + * + * Items are matched by `hostname` rather than server `id`; the delta payload uses + * `hostname` as the identity field. + */ +export const HOSTNAMES_ARRAY_POLICY: ArrayPolicy = { + identityKey: 'hostname', + idFieldKey: 'hostname', +} + +/** * * Uses `_deltaId` in the delta payload when items lack a server `id`, avoiding * invalid Guid values in the `id` field expected by the API. diff --git a/src/packages/core/src/functions/index.ts b/src/packages/core/src/functions/index.ts index c5fa51f..1aef24b 100644 --- a/src/packages/core/src/functions/index.ts +++ b/src/packages/core/src/functions/index.ts @@ -15,6 +15,7 @@ import { deepDelta, deltaHasOperations, ENTITY_SCOPES_ARRAY_POLICY, + HOSTNAMES_ARRAY_POLICY, VERSIONS_ARRAY_POLICY, deepEqual, deepEqualArrays, @@ -63,6 +64,7 @@ export { deepDelta, deltaHasOperations, ENTITY_SCOPES_ARRAY_POLICY, + HOSTNAMES_ARRAY_POLICY, VERSIONS_ARRAY_POLICY, deepEqual, deepEqualArrays, diff --git a/src/packages/core/src/index.ts b/src/packages/core/src/index.ts index d64cd59..adaddda 100644 --- a/src/packages/core/src/index.ts +++ b/src/packages/core/src/index.ts @@ -4,3 +4,4 @@ export * from './http' export * from './signalr' export { readIdentity, writeIdentity, removeIdentity } from './localStorage/identity' export { useFormState } from './hooks/useFormState' +export * from './oauth' diff --git a/src/packages/core/test/functions/deep/deepDelta.test.ts b/src/packages/core/test/functions/deep/deepDelta.test.ts index 577a528..62dd505 100644 --- a/src/packages/core/test/functions/deep/deepDelta.test.ts +++ b/src/packages/core/test/functions/deep/deepDelta.test.ts @@ -1,101 +1,1319 @@ import { COLLECTION_ITEM_OPERATION, PatchOperation } from '@maks-it.com/webui-contracts' import { deepDelta, deltaHasOperations } from '../../../src/functions/deep/deepDelta' +import { + ENTITY_SCOPES_ARRAY_POLICY, + HOSTNAMES_ARRAY_POLICY, + VERSIONS_ARRAY_POLICY, +} from '../../../src/functions/deep/patchCollectionPolicies' + +type EntityScopeRow = { + id?: string + entityId: string + entityType: number + scope: number +} + +const orgScope = (overrides: Partial & Pick): EntityScopeRow => ({ + ...overrides, +}) describe('deepDelta', () => { - it('detects primitive field changes', () => { - const backup = { name: 'old', count: 1 } - const form = { name: 'new', count: 1 } + describe('primitive fields', () => { + /** + * backup: + * { name: 'old', count: 1 } + * form: + * { name: 'new', count: 1 } + * delta: + * { + * name: 'new', + * operations: { name: PatchOperation.SetField } + * } + * Note: `count` is omitted because it did not change. + */ + it('detects primitive field changes', () => { + const backup = { name: 'old', count: 1 } + const form = { name: 'new', count: 1 } - const delta = deepDelta(form, backup) + const delta = deepDelta(form, backup) - expect(delta.name).toBe('new') - expect(delta.operations?.name).toBe(PatchOperation.SetField) - expect(delta.count).toBeUndefined() + expect(delta.name).toBe('new') + expect(delta.operations?.name).toBe(PatchOperation.SetField) + expect(delta.count).toBeUndefined() + }) + + /** + * backup: + * { name: 'value', optional: 'present' } + * form: + * { name: 'value', optional: null } + * delta: + * { + * operations: { optional: PatchOperation.RemoveField } + * } + * Note: removed fields are not present in the delta body — only the RemoveField operation. + */ + it('marks nullish values as RemoveField', () => { + const backup = { name: 'value', optional: 'present' } + const form = { name: 'value', optional: null } + + const delta = deepDelta(form, backup) + + expect(delta.optional).toBeUndefined() + expect(delta.operations?.optional).toBe(PatchOperation.RemoveField) + }) + + /** + * backup: + * { name: 'same', count: 1 } + * form: + * { name: 'same', count: 1 } + * delta: + * {} + */ + it('skips unchanged primitives', () => { + const state = { name: 'same', count: 1 } + const delta = deepDelta(state, state) + + expect(delta).toEqual({}) + expect(deltaHasOperations(delta)).toBe(false) + }) }) - it('marks nullish values as RemoveField', () => { - const backup = { name: 'value', optional: 'present' } - const form = { name: 'value', optional: null } + describe('primitive arrays', () => { + /** + * backup: + * { tags: ['a', 'b'] } + * form: + * { tags: ['a', 'b', 'c'] } + * delta: + * { + * tags: ['a', 'b', 'c'], + * operations: { tags: PatchOperation.SetField } + * } + * Note: primitive arrays have no per-item identity — the whole array is replaced. + */ + it('replaces primitive arrays when values differ', () => { + const backup = { tags: ['a', 'b'] } + const form = { tags: ['a', 'b', 'c'] } - const delta = deepDelta(form, backup) + const delta = deepDelta(form, backup) - expect(delta.optional).toBeUndefined() - expect(delta.operations?.optional).toBe(PatchOperation.RemoveField) + expect(delta.tags).toEqual(['a', 'b', 'c']) + expect(delta.operations?.tags).toBe(PatchOperation.SetField) + }) + + /** + * backup: + * { tags: ['a', 'b'] } + * form: + * { tags: ['a', 'b'] } + * delta: + * {} + */ + it('skips unchanged primitive arrays', () => { + const delta = deepDelta({ tags: ['a', 'b'] }, { tags: ['a', 'b'] }) + + expect(delta.tags).toBeUndefined() + expect(delta.operations?.tags).toBeUndefined() + }) }) - it('replaces primitive arrays when values differ', () => { - const backup = { tags: ['a', 'b'] } - const form = { tags: ['a', 'b', 'c'] } + describe('identifiable object arrays (default id policy)', () => { + /** + * backup: + * { + * items: [ + * { id: '1', name: 'first' }, + * { id: '2', name: 'second' } + * ] + * } + * form: + * { + * items: [ + * { id: '1', name: 'updated' }, + * { id: '3', name: 'new' } + * ] + * } + * delta: + * { + * items: [ + * { id: '1', name: 'updated', operations: { name: PatchOperation.SetField } }, + * { id: '3', name: 'new', operations: { collectionItemOperation: PatchOperation.AddToCollection } }, + * { id: '2', operations: { collectionItemOperation: PatchOperation.RemoveFromCollection } } + * ] + * } + */ + it('diffs items by server-assigned id: update, add, remove', () => { + const backup = { + items: [ + { id: '1', name: 'first' }, + { id: '2', name: 'second' }, + ], + } + const form = { + items: [ + { id: '1', name: 'updated' }, + { id: '3', name: 'new' }, + ], + } - const delta = deepDelta(form, backup) + const delta = deepDelta(form, backup) - expect(delta.tags).toEqual(['a', 'b', 'c']) - expect(delta.operations?.tags).toBe(PatchOperation.SetField) - }) + expect(delta.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: '1', name: 'updated' }), + expect.objectContaining({ + id: '3', + name: 'new', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }, + }), + expect.objectContaining({ + id: '2', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }, + }), + ]) + ) + }) - it('skips unchanged primitive arrays', () => { - const backup = { tags: ['a', 'b'] } - const form = { tags: ['b', 'a'] } + /** + * backup: + * { + * entityScopes: [ + * { id: 'scope-1', entityId: 'org-1', entityType: 0, scope: 3 } + * ] + * } + * form: + * { + * entityScopes: [ + * { id: 'scope-1', entityId: 'org-1', entityType: 0, scope: 3 } + * ] + * } + * delta: + * {} + * Note: unchanged rows with a server id must not appear as `{ id: 'scope-1' }` placeholders. + */ + it('omits unchanged items that only have a server id', () => { + const unchanged = { id: 'scope-1', entityId: 'org-1', entityType: 0, scope: 3 } + const backup = { entityScopes: [unchanged] } + const form = { entityScopes: [{ ...unchanged }] } - const delta = deepDelta({ tags: ['a', 'b'] }, { tags: ['a', 'b'] }) + const delta = deepDelta(form, backup) - expect(delta.tags).toBeUndefined() - expect(delta.operations?.tags).toBeUndefined() - }) + expect(delta.entityScopes).toBeUndefined() + expect(deltaHasOperations(delta)).toBe(false) + }) - it('diffs identifiable object arrays by id', () => { - const backup = { - items: [{ id: '1', name: 'first' }, { id: '2', name: 'second' }], - } - const form = { - items: [{ id: '1', name: 'updated' }, { id: '3', name: 'new' }], - } + /** + * backup: + * { + * entityScopes: [ + * { id: 'scope-1', entityId: 'org-1', entityType: 0, scope: 1 } + * ] + * } + * form: + * { + * entityScopes: [ + * { id: 'scope-1', entityId: 'org-1', entityType: 0, scope: 3 } + * ] + * } + * delta: + * { + * entityScopes: [ + * { id: 'scope-1', scope: 3, operations: { scope: PatchOperation.SetField } } + * ] + * } + * Note: only changed fields are included on the item delta (plus `id` for backend lookup). + */ + it('emits field-level update for changed properties on an existing id', () => { + const backup = { + entityScopes: [{ id: 'scope-1', entityId: 'org-1', entityType: 0, scope: 1 }], + } + const form = { + entityScopes: [{ id: 'scope-1', entityId: 'org-1', entityType: 0, scope: 3 }], + } - const delta = deepDelta(form, backup) + const delta = deepDelta(form, backup) - expect(delta.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: '1', name: 'updated' }), + expect(delta.entityScopes).toEqual([ expect.objectContaining({ - id: '3', - name: 'new', + id: 'scope-1', + scope: 3, + operations: expect.objectContaining({ scope: PatchOperation.SetField }), + }), + ]) + }) + }) + + describe('identityKey / idFieldKey policies', () => { + /** + * backup: + * { hostnames: [{ hostname: 'a.example.com', enabled: true }] } + * form: + * { hostnames: [{ hostname: 'a.example.com', enabled: false }] } + * options: + * { arrays: { hostnames: { identityKey: 'hostname', idFieldKey: 'hostname' } } } + * delta: + * { + * hostnames: [ + * { + * hostname: 'a.example.com', + * enabled: false, + * operations: { enabled: PatchOperation.SetField } + * } + * ] + * } + * Note: items without `id` are matched by `identityKey` instead. + */ + it('uses identityKey when items have no id', () => { + const backup = { hostnames: [{ hostname: 'a.example.com', enabled: true }] } + const form = { hostnames: [{ hostname: 'a.example.com', enabled: false }] } + + const delta = deepDelta(form, backup, { + arrays: { hostnames: { identityKey: 'hostname', idFieldKey: 'hostname' } }, + }) + + expect(delta.hostnames).toEqual([ + expect.objectContaining({ + hostname: 'a.example.com', + enabled: false, + operations: expect.objectContaining({ enabled: PatchOperation.SetField }), + }), + ]) + }) + + /** + * backup: + * { rows: [{ label: 'a' }] } + * form: + * { rows: [{ label: 'b' }] } + * delta: + * { + * rows: [{ label: 'b' }], + * operations: { rows: PatchOperation.SetField } + * } + * Note: without `id` or `identityKey` policy the array cannot be itemized — full replace. + */ + it('replaces object arrays without id or identityKey', () => { + const backup = { rows: [{ label: 'a' }] } + const form = { rows: [{ label: 'b' }] } + + const delta = deepDelta(form, backup) + + expect(delta.rows).toEqual([{ label: 'b' }]) + expect(delta.operations?.rows).toBe(PatchOperation.SetField) + }) + }) + + describe('ENTITY_SCOPES_ARRAY_POLICY', () => { + const policyOptions = { arrays: { entityScopes: ENTITY_SCOPES_ARRAY_POLICY } } + + /** + * Regression: vault API key edit — add a scope while an existing scope is unchanged. + * + * backup: + * { + * entityScopes: [ + * { id: '1111...', entityId: 'org-1', entityType: 0, scope: 3 } + * ] + * } + * form: + * { + * entityScopes: [ + * { id: '1111...', entityId: 'org-1', entityType: 0, scope: 3 }, + * { entityId: 'app-1', entityType: 1, scope: 1 } + * ] + * } + * delta (must NOT include `{ id: '1111...' }` for the unchanged row): + * { + * entityScopes: [ + * { + * entityId: 'app-1', + * entityType: 1, + * scope: 1, + * operations: { collectionItemOperation: PatchOperation.AddToCollection } + * } + * ] + * } + */ + it('does not emit id-only placeholders for unchanged scopes with server ids', () => { + const existing = orgScope({ + id: '11111111-1111-1111-1111-111111111111', + entityId: 'org-1', + entityType: 0, + scope: 3, + }) + const backup = { entityScopes: [existing] } + const form = { + entityScopes: [ + existing, + orgScope({ + entityId: 'app-1', + entityType: 1, + scope: 1, + }), + ], + } + + const delta = deepDelta(form, backup, policyOptions) + + expect(delta.entityScopes).toHaveLength(1) + expect(delta.entityScopes?.[0]).toEqual( + expect.objectContaining({ + entityId: 'app-1', + entityType: 1, + scope: 1, + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }, + }) + ) + expect(delta.entityScopes?.[0]).not.toHaveProperty('id') + }) + + /** + * backup: + * { entityScopes: [] } + * form: + * { + * entityScopes: [ + * { entityId: 'org-1', entityType: 0, scope: 3 } + * ] + * } + * delta: + * { + * entityScopes: [ + * { + * entityId: 'org-1', + * entityType: 0, + * scope: 3, + * operations: { collectionItemOperation: PatchOperation.AddToCollection } + * } + * ] + * } + * Note: new rows must not receive a server `id` Guid in the payload. + */ + it('adds a new scope with AddToCollection and full scope fields (no server id)', () => { + const backup = { entityScopes: [] as EntityScopeRow[] } + const form = { + entityScopes: [ + orgScope({ entityId: 'org-1', entityType: 0, scope: 3 }), + ], + } + + const delta = deepDelta(form, backup, policyOptions) + + expect(delta.entityScopes).toEqual([ + expect.objectContaining({ + entityId: 'org-1', + entityType: 0, + scope: 3, operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }, }), + ]) + expect(delta.entityScopes?.[0]).not.toHaveProperty('id') + }) + + /** + * backup: + * { entityScopes: [] } + * form: + * { + * entityScopes: [ + * { entityId: 'org-1', entityType: 0, scope: 3 } + * ] + * } + * delta (no ENTITY_SCOPES_ARRAY_POLICY — rows have no server id): + * { + * entityScopes: [{ entityId: 'org-1', entityType: 0, scope: 3 }], + * operations: { entityScopes: PatchOperation.SetField } + * } + */ + it('replaces entityScopes entirely when rows lack server ids and no identityKey policy', () => { + const backup = { entityScopes: [] as EntityScopeRow[] } + const form = { + entityScopes: [ + orgScope({ entityId: 'org-1', entityType: 0, scope: 3 }), + ], + } + + const delta = deepDelta(form, backup) + + expect(delta.entityScopes).toEqual([ + { entityId: 'org-1', entityType: 0, scope: 3 }, + ]) + expect(delta.operations?.entityScopes).toBe(PatchOperation.SetField) + }) + + /** + * backup: + * { + * entityScopes: [ + * { id: '2222...', entityId: 'org-1', entityType: 0, scope: 1 } + * ] + * } + * form: + * { + * entityScopes: [ + * { id: '2222...', entityId: 'org-1', entityType: 0, scope: 3 } + * ] + * } + * delta: + * { + * entityScopes: [ + * { id: '2222...', scope: 3, operations: { scope: PatchOperation.SetField } } + * ] + * } + */ + it('updates permissions on an existing scope by server id', () => { + const backup = { + entityScopes: [ + orgScope({ + id: '22222222-2222-2222-2222-222222222222', + entityId: 'org-1', + entityType: 0, + scope: 1, + }), + ], + } + const form = { + entityScopes: [ + orgScope({ + id: '22222222-2222-2222-2222-222222222222', + entityId: 'org-1', + entityType: 0, + scope: 3, + }), + ], + } + + const delta = deepDelta(form, backup, policyOptions) + + expect(delta.entityScopes).toEqual([ expect.objectContaining({ - id: '2', + id: '22222222-2222-2222-2222-222222222222', + scope: 3, + operations: expect.objectContaining({ scope: PatchOperation.SetField }), + }), + ]) + }) + + /** + * backup: + * { + * entityScopes: [ + * { id: '3333...', entityId: 'org-1', entityType: 0, scope: 3 } + * ] + * } + * form: + * { entityScopes: [] } + * delta: + * { + * entityScopes: [ + * { id: '3333...', operations: { collectionItemOperation: PatchOperation.RemoveFromCollection } } + * ] + * } + */ + it('removes a scope by server id', () => { + const backup = { + entityScopes: [ + orgScope({ + id: '33333333-3333-3333-3333-333333333333', + entityId: 'org-1', + entityType: 0, + scope: 3, + }), + ], + } + const form = { entityScopes: [] as EntityScopeRow[] } + + const delta = deepDelta(form, backup, policyOptions) + + expect(delta.entityScopes).toEqual([ + expect.objectContaining({ + id: '33333333-3333-3333-3333-333333333333', operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }, }), ]) - ) - }) - - it('uses identityKey when items have no id', () => { - const backup = { hostnames: [{ hostname: 'a.example.com', enabled: true }] } - const form = { hostnames: [{ hostname: 'a.example.com', enabled: false }] } - - const delta = deepDelta(form, backup, { - arrays: { hostnames: { identityKey: 'hostname', idFieldKey: 'hostname' } }, }) - expect(delta.hostnames).toEqual([ - expect.objectContaining({ - hostname: 'a.example.com', - enabled: false, - operations: expect.objectContaining({ enabled: PatchOperation.SetField }), - }), - ]) + /** + * Full vault API key save: toggle isGlobalAdmin + add application scope; vault org scope unchanged. + * + * backup: + * { + * entityScopes: [ + * { id: 'aaaa...', entityId: 'org-com', entityType: 0, scope: 3 } + * ], + * isGlobalAdmin: false + * } + * form: + * { + * entityScopes: [ + * { id: 'aaaa...', entityId: 'org-com', entityType: 0, scope: 3 }, + * { entityId: 'app-fixture', entityType: 1, scope: 1 } + * ], + * isGlobalAdmin: true + * } + * delta: + * { + * isGlobalAdmin: true, + * operations: { isGlobalAdmin: PatchOperation.SetField }, + * entityScopes: [ + * { + * entityId: 'app-fixture', + * entityType: 1, + * scope: 1, + * operations: { collectionItemOperation: PatchOperation.AddToCollection } + * } + * ] + * } + */ + it('matches unchanged and changed scopes in a mixed edit (vault API key scenario)', () => { + const vaultScope = orgScope({ + id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + entityId: 'org-com', + entityType: 0, + scope: 3, + }) + const backup = { entityScopes: [vaultScope] } + const form = { + entityScopes: [ + vaultScope, + orgScope({ + entityId: 'app-fixture', + entityType: 1, + scope: 1, + }), + ], + isGlobalAdmin: true, + } + const backupWithAdmin = { ...backup, isGlobalAdmin: false } + + const delta = deepDelta(form, backupWithAdmin, { + arrays: { entityScopes: ENTITY_SCOPES_ARRAY_POLICY }, + }) + + expect(delta.isGlobalAdmin).toBe(true) + expect(delta.operations?.isGlobalAdmin).toBe(PatchOperation.SetField) + expect(delta.entityScopes).toHaveLength(1) + expect(delta.entityScopes?.[0]).toEqual( + expect.objectContaining({ + entityId: 'app-fixture', + entityType: 1, + scope: 1, + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }, + }) + ) + }) + }) + + describe('VERSIONS_ARRAY_POLICY', () => { + const policyOptions = { arrays: { versions: VERSIONS_ARRAY_POLICY } } + + /** + * backup: + * { versions: [{ id: 'ver-1', version: 1, value: 'secret' }] } + * form: + * { versions: [{ id: 'ver-1', version: 1, value: 'secret' }] } + * delta: + * {} + */ + it('omits unchanged versions that have server ids', () => { + const version = { id: 'ver-1', version: 1, value: 'secret' } + const delta = deepDelta( + { versions: [version] }, + { versions: [{ ...version }] }, + policyOptions + ) + + expect(delta.versions).toBeUndefined() + }) + + /** + * backup: + * { versions: [{ id: 'ver-1', version: 1, value: 'secret' }] } + * form: + * { versions: [{ version: 2, value: 'next' }] } + * delta: + * { + * versions: [ + * { + * version: 2, + * value: 'next', + * operations: { collectionItemOperation: PatchOperation.AddToCollection } + * }, + * { + * id: 'ver-1', + * operations: { collectionItemOperation: PatchOperation.RemoveFromCollection } + * } + * ] + * } + */ + it('adds a new version row without assigning id', () => { + const delta = deepDelta( + { versions: [{ version: 2, value: 'next' }] }, + { versions: [{ id: 'ver-1', version: 1, value: 'secret' }] }, + policyOptions + ) + + expect(delta.versions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + version: 2, + value: 'next', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }, + }), + expect.objectContaining({ + id: 'ver-1', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }, + }), + ]) + ) + }) + + /** + * Vault EditSecret — append v2 while v1 is unchanged. + * + * backup: + * { name: 'db-password', versions: [{ id: 'ver-1', version: 'v1', value: 'old' }] } + * form: + * { + * name: 'db-password', + * versions: [ + * { id: 'ver-1', version: 'v1', value: 'old' }, + * { version: 'v2', value: 'new' } + * ] + * } + * delta: + * { + * versions: [ + * { + * version: 'v2', + * value: 'new', + * operations: { collectionItemOperation: PatchOperation.AddToCollection } + * } + * ] + * } + */ + it('adds a new version while existing server versions stay omitted', () => { + const delta = deepDelta( + { + name: 'db-password', + versions: [ + { id: 'ver-1', version: 'v1', value: 'old' }, + { version: 'v2', value: 'new' }, + ], + }, + { + name: 'db-password', + versions: [{ id: 'ver-1', version: 'v1', value: 'old' }], + }, + policyOptions + ) + + expect(delta.name).toBeUndefined() + expect(delta.versions).toHaveLength(1) + expect(delta.versions?.[0]).toEqual( + expect.objectContaining({ + version: 'v2', + value: 'new', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }, + }) + ) + expect(delta.versions?.[0]).not.toHaveProperty('id') + }) + + /** + * backup: + * { versions: [{ id: 'ver-1', version: 'v1', value: 'old', expiresAt: '2026-01-01' }] } + * form: + * { versions: [{ id: 'ver-1', version: 'v1', value: 'rotated', expiresAt: '2026-12-31' }] } + * delta: + * { + * versions: [ + * { + * id: 'ver-1', + * value: 'rotated', + * expiresAt: '2026-12-31', + * operations: { + * value: PatchOperation.SetField, + * expiresAt: PatchOperation.SetField + * } + * } + * ] + * } + */ + it('updates value and expiry on an existing version by server id', () => { + const delta = deepDelta( + { + versions: [{ id: 'ver-1', version: 'v1', value: 'rotated', expiresAt: '2026-12-31' }], + }, + { + versions: [{ id: 'ver-1', version: 'v1', value: 'old', expiresAt: '2026-01-01' }], + }, + policyOptions + ) + + expect(delta.versions).toEqual([ + expect.objectContaining({ + id: 'ver-1', + value: 'rotated', + expiresAt: '2026-12-31', + operations: expect.objectContaining({ + value: PatchOperation.SetField, + expiresAt: PatchOperation.SetField, + }), + }), + ]) + }) + + /** + * backup: + * { name: 'old-name', versions: [{ id: 'ver-1', version: 'v1', value: 'secret' }] } + * form: + * { name: 'new-name', versions: [{ id: 'ver-1', version: 'v1', value: 'secret' }] } + * delta: + * { + * name: 'new-name', + * operations: { name: PatchOperation.SetField } + * } + */ + it('renames a secret without emitting unchanged version rows', () => { + const version = { id: 'ver-1', version: 'v1', value: 'secret' } + const delta = deepDelta( + { name: 'new-name', versions: [version] }, + { name: 'old-name', versions: [{ ...version }] }, + policyOptions + ) + + expect(delta.name).toBe('new-name') + expect(delta.operations?.name).toBe(PatchOperation.SetField) + expect(delta.versions).toBeUndefined() + }) + }) + + describe('HOSTNAMES_ARRAY_POLICY (certs EditAccount)', () => { + const policyOptions = { arrays: { hostnames: HOSTNAMES_ARRAY_POLICY } } + + type HostnameRow = { hostname: string; isDisabled: boolean } + + const hostname = (overrides: Partial & Pick): HostnameRow => ({ + isDisabled: false, + ...overrides, + }) + + /** + * backup: + * { hostnames: [{ hostname: 'www.example.com', isDisabled: false }] } + * form: + * { + * hostnames: [ + * { hostname: 'www.example.com', isDisabled: false }, + * { hostname: 'api.example.com', isDisabled: false } + * ] + * } + * delta: + * { + * hostnames: [ + * { + * hostname: 'api.example.com', + * isDisabled: false, + * operations: { collectionItemOperation: PatchOperation.AddToCollection } + * } + * ] + * } + */ + it('adds a hostname matched by hostname identity', () => { + const delta = deepDelta( + { + hostnames: [ + hostname({ hostname: 'www.example.com' }), + hostname({ hostname: 'api.example.com' }), + ], + }, + { hostnames: [hostname({ hostname: 'www.example.com' })] }, + policyOptions + ) + + expect(delta.hostnames).toHaveLength(1) + expect(delta.hostnames?.[0]).toEqual( + expect.objectContaining({ + hostname: 'api.example.com', + isDisabled: false, + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }, + }) + ) + }) + + /** + * backup: + * { + * hostnames: [ + * { hostname: 'www.example.com', isDisabled: false }, + * { hostname: 'legacy.example.com', isDisabled: false } + * ] + * } + * form: + * { hostnames: [{ hostname: 'www.example.com', isDisabled: false }] } + * delta: + * { + * hostnames: [ + * { + * hostname: 'legacy.example.com', + * operations: { collectionItemOperation: PatchOperation.RemoveFromCollection } + * } + * ] + * } + */ + it('removes a hostname by hostname identity', () => { + const delta = deepDelta( + { hostnames: [hostname({ hostname: 'www.example.com' })] }, + { + hostnames: [ + hostname({ hostname: 'www.example.com' }), + hostname({ hostname: 'legacy.example.com' }), + ], + }, + policyOptions + ) + + expect(delta.hostnames).toEqual([ + expect.objectContaining({ + hostname: 'legacy.example.com', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }, + }), + ]) + }) + + /** + * Certs EditAccount mixed save: primitives + contacts + hostnames. + * + * backup: + * { + * isDisabled: false, + * description: 'Acme', + * contacts: ['ops@acme.com'], + * hostnames: [ + * { hostname: 'www.acme.com', isDisabled: false }, + * { hostname: 'api.acme.com', isDisabled: false } + * ] + * } + * form: + * { + * isDisabled: true, + * description: 'Acme Corp', + * contacts: ['ops@acme.com', 'billing@acme.com'], + * hostnames: [ + * { hostname: 'www.acme.com', isDisabled: true }, + * { hostname: 'portal.acme.com', isDisabled: false } + * ] + * } + */ + it('combines primitive, contact, and hostname changes in one delta', () => { + const delta = deepDelta( + { + isDisabled: true, + description: 'Acme Corp', + contacts: ['ops@acme.com', 'billing@acme.com'], + hostnames: [ + hostname({ hostname: 'www.acme.com', isDisabled: true }), + hostname({ hostname: 'portal.acme.com' }), + ], + }, + { + isDisabled: false, + description: 'Acme', + contacts: ['ops@acme.com'], + hostnames: [ + hostname({ hostname: 'www.acme.com' }), + hostname({ hostname: 'api.acme.com' }), + ], + }, + policyOptions + ) + + expect(delta.isDisabled).toBe(true) + expect(delta.description).toBe('Acme Corp') + expect(delta.contacts).toEqual(['ops@acme.com', 'billing@acme.com']) + expect(delta.operations?.isDisabled).toBe(PatchOperation.SetField) + expect(delta.operations?.description).toBe(PatchOperation.SetField) + expect(delta.operations?.contacts).toBe(PatchOperation.SetField) + + expect(delta.hostnames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + hostname: 'www.acme.com', + isDisabled: true, + operations: expect.objectContaining({ isDisabled: PatchOperation.SetField }), + }), + expect.objectContaining({ + hostname: 'portal.acme.com', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }, + }), + expect.objectContaining({ + hostname: 'api.acme.com', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }, + }), + ]) + ) + }) + }) + + describe('consumer form scenarios (vault / certs)', () => { + /** + * Vault EditOrganization — rename only. + * + * backup: { name: 'Org A' } + * form: { name: 'Org B' } + */ + it('emits a single SetField for organization rename', () => { + const delta = deepDelta({ name: 'Org B' }, { name: 'Org A' }) + + expect(delta).toEqual({ + name: 'Org B', + operations: { name: PatchOperation.SetField }, + }) + }) + + /** + * Certs/Vault EditUser — profile fields without scope churn. + * + * backup: + * { + * username: 'alice', + * email: 'alice@example.com', + * entityScopes: [{ id: 'scope-1', entityId: 'org-1', entityType: 0, scope: 3 }] + * } + * form: + * { + * username: 'alice', + * email: 'alice.new@example.com', + * entityScopes: [{ id: 'scope-1', entityId: 'org-1', entityType: 0, scope: 3 }] + * } + */ + it('updates user email without emitting unchanged entityScopes', () => { + const scope = { id: 'scope-1', entityId: 'org-1', entityType: 0, scope: 3 } + const delta = deepDelta( + { username: 'alice', email: 'alice.new@example.com', entityScopes: [scope] }, + { username: 'alice', email: 'alice@example.com', entityScopes: [{ ...scope }] }, + { arrays: { entityScopes: ENTITY_SCOPES_ARRAY_POLICY } } + ) + + expect(delta.email).toBe('alice.new@example.com') + expect(delta.operations?.email).toBe(PatchOperation.SetField) + expect(delta.entityScopes).toBeUndefined() + }) + }) + + describe('ENTITY_SCOPES_ARRAY_POLICY edge cases', () => { + const policyOptions = { arrays: { entityScopes: ENTITY_SCOPES_ARRAY_POLICY } } + + /** + * Synthetic identity includes `scope`, so changing scope on a row without server `id` + * is modeled as remove + add (not an in-place update). + * + * backup: + * { entityScopes: [{ entityId: 'org-1', entityType: 0, scope: 1 }] } + * form: + * { entityScopes: [{ entityId: 'org-1', entityType: 0, scope: 3 }] } + */ + it('treats scope bitmask change on id-less rows as remove and add', () => { + const delta = deepDelta( + { entityScopes: [orgScope({ entityId: 'org-1', entityType: 0, scope: 3 })] }, + { entityScopes: [orgScope({ entityId: 'org-1', entityType: 0, scope: 1 })] }, + policyOptions + ) + + expect(delta.entityScopes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entityId: 'org-1', + entityType: 0, + scope: 3, + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }, + }), + expect.objectContaining({ + _deltaId: 'org-1-0-1', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }, + }), + ]) + ) + }) + + /** + * backup: + * { + * entityScopes: [ + * { id: 'scope-a', entityId: 'org-1', entityType: 0, scope: 1 }, + * { id: 'scope-b', entityId: 'app-1', entityType: 1, scope: 3 } + * ] + * } + * form: + * { + * entityScopes: [ + * { id: 'scope-a', entityId: 'org-1', entityType: 0, scope: 7 }, + * ] + * } + */ + it('removes one scope and updates another in the same edit', () => { + const delta = deepDelta( + { + entityScopes: [ + orgScope({ + id: 'scope-a', + entityId: 'org-1', + entityType: 0, + scope: 7, + }), + ], + }, + { + entityScopes: [ + orgScope({ + id: 'scope-a', + entityId: 'org-1', + entityType: 0, + scope: 1, + }), + orgScope({ + id: 'scope-b', + entityId: 'app-1', + entityType: 1, + scope: 3, + }), + ], + }, + policyOptions + ) + + expect(delta.entityScopes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'scope-a', + scope: 7, + operations: expect.objectContaining({ scope: PatchOperation.SetField }), + }), + expect.objectContaining({ + id: 'scope-b', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }, + }), + ]) + ) + }) + }) + + describe('advanced ArrayPolicy options', () => { + type RoleAssignment = { + id?: string + organizationId: string + role: string | null + } + + /** + * Re-parenting example: when `organizationId` changes, emit remove + add. + * + * policy: + * { rootKey: 'organizationId' } + */ + it('re-parents an assignment when rootKey changes', () => { + const policy = { rootKey: 'organizationId' as const } + const delta = deepDelta( + { + assignments: [ + { id: 'assign-1', organizationId: 'org-b', role: 'Reader' }, + ], + }, + { + assignments: [ + { id: 'assign-1', organizationId: 'org-a', role: 'Reader' }, + ], + }, + { arrays: { assignments: policy } } + ) + + expect(delta.assignments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'assign-1', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }, + }), + expect.objectContaining({ + id: 'assign-1', + organizationId: 'org-b', + role: 'Reader', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.AddToCollection }, + }), + ]) + ) + }) + + /** + * policy: + * { + * rootKey: 'organizationId', + * childArrayKeys: ['applicationRoles'], + * dropChildrenOnRootChange: true + * } + */ + it('clears child arrays when dropChildrenOnRootChange is true', () => { + const policy = { + rootKey: 'organizationId' as const, + childArrayKeys: ['applicationRoles'] as const, + dropChildrenOnRootChange: true, + } + + const delta = deepDelta( + { + memberships: [ + { + id: 'mem-1', + organizationId: 'org-b', + applicationRoles: [{ id: 'role-1', role: 'Admin' }], + }, + ], + }, + { + memberships: [ + { + id: 'mem-1', + organizationId: 'org-a', + applicationRoles: [{ id: 'role-1', role: 'Admin' }], + }, + ], + }, + { arrays: { memberships: policy } } + ) + + const addItem = delta.memberships?.find( + item => item.operations?.[COLLECTION_ITEM_OPERATION] === PatchOperation.AddToCollection + ) + + expect(addItem).toEqual( + expect.objectContaining({ + id: 'mem-1', + organizationId: 'org-b', + applicationRoles: [], + }) + ) + }) + + /** + * policy: + * { roleFieldKey: 'role', deleteItemWhenRoleRemoved: true } + */ + it('removes an item when role becomes null and deleteItemWhenRoleRemoved is enabled', () => { + const policy = { + roleFieldKey: 'role' as const, + deleteItemWhenRoleRemoved: true, + } + + const delta = deepDelta< { grants: RoleAssignment[] }>( + { grants: [{ id: 'grant-1', organizationId: 'org-1', role: null }] }, + { grants: [{ id: 'grant-1', organizationId: 'org-1', role: 'Admin' }] }, + { arrays: { grants: policy } } + ) + + expect(delta.grants).toEqual([ + expect.objectContaining({ + id: 'grant-1', + operations: { [COLLECTION_ITEM_OPERATION]: PatchOperation.RemoveFromCollection }, + }), + ]) + }) + + /** + * policy: + * { roleFieldKey: 'role', deleteItemWhenRoleRemoved: false } + * + * Note: role → null still maps to RemoveField on the item field; the row is emitted + * because `role` is present in backup and cleared in form. + */ + it('updates role in place when deleteItemWhenRoleRemoved is false', () => { + const policy = { + roleFieldKey: 'role' as const, + deleteItemWhenRoleRemoved: false, + } + + const delta = deepDelta<{ grants: RoleAssignment[] }>( + { grants: [{ id: 'grant-1', organizationId: 'org-1', role: 'Reader' }] }, + { grants: [{ id: 'grant-1', organizationId: 'org-1', role: 'Admin' }] }, + { arrays: { grants: policy } } + ) + + expect(delta.grants).toEqual([ + expect.objectContaining({ + id: 'grant-1', + role: 'Reader', + operations: expect.objectContaining({ role: PatchOperation.SetField }), + }), + ]) + }) + }) + + describe('nested objects', () => { + /** + * backup: + * { + * profile: { + * displayName: 'old', + * settings: { theme: 'light' } + * } + * } + * form: + * { + * profile: { + * displayName: 'new', + * settings: { theme: 'light' } + * } + * } + * delta: + * { + * profile: { + * displayName: 'new', + * operations: { displayName: PatchOperation.SetField } + * } + * } + * Note: nested `settings` is omitted because it did not change. + */ + it('diffs nested object fields', () => { + const backup = { profile: { displayName: 'old', settings: { theme: 'light' } } } + const form = { profile: { displayName: 'new', settings: { theme: 'light' } } } + + const delta = deepDelta(form, backup) + + expect(delta.profile).toEqual( + expect.objectContaining({ + displayName: 'new', + operations: expect.objectContaining({ displayName: PatchOperation.SetField }), + }) + ) + expect((delta.profile as { settings?: unknown }).settings).toBeUndefined() + }) }) }) describe('deltaHasOperations', () => { + /** + * delta: {} + * result: false + */ it('returns false for empty delta', () => { expect(deltaHasOperations({})).toBe(false) }) + /** + * delta: + * { operations: { name: PatchOperation.SetField } } + * result: true + */ it('returns true when top-level operations exist', () => { expect(deltaHasOperations({ operations: { name: PatchOperation.SetField } })).toBe(true) }) + /** + * delta: + * { nested: { operations: { field: PatchOperation.SetField } } } + * result: true + */ it('returns true for nested object operations', () => { expect( deltaHasOperations({ @@ -104,6 +1322,15 @@ describe('deltaHasOperations', () => { ).toBe(true) }) + /** + * delta: + * { + * items: [ + * { operations: { collectionItemOperation: PatchOperation.AddToCollection } } + * ] + * } + * result: true + */ it('returns true for array item operations', () => { expect( deltaHasOperations({ @@ -111,4 +1338,36 @@ describe('deltaHasOperations', () => { }) ).toBe(true) }) + + /** + * backup: + * { + * entityScopes: [{ id: '1111...', entityId: 'org-1', entityType: 0, scope: 3 }], + * description: 'same' + * } + * form: + * { + * entityScopes: [{ id: '1111...', entityId: 'org-1', entityType: 0, scope: 3 }], + * description: 'same' + * } + * delta: + * {} + * result: false (no spurious operations from unchanged scope placeholders) + */ + it('returns false when entity scope delta would only contain unchanged id placeholders', () => { + const unchanged = { + id: '11111111-1111-1111-1111-111111111111', + entityId: 'org-1', + entityType: 0, + scope: 3, + } + const delta = deepDelta( + { entityScopes: [unchanged], description: 'same' }, + { entityScopes: [{ ...unchanged }], description: 'same' }, + { arrays: { entityScopes: ENTITY_SCOPES_ARRAY_POLICY } } + ) + + expect(delta.entityScopes).toBeUndefined() + expect(deltaHasOperations(delta)).toBe(false) + }) }) diff --git a/utils/src/Force-AmendTaggedCommit.bat b/utils/Force-AmendTaggedCommit.bat similarity index 100% rename from utils/src/Force-AmendTaggedCommit.bat rename to utils/Force-AmendTaggedCommit.bat diff --git a/utils/Invoke-ReleasePackage-HA.bat b/utils/Invoke-ReleasePackage-HA.bat new file mode 100644 index 0000000..541f394 --- /dev/null +++ b/utils/Invoke-ReleasePackage-HA.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +pwsh -NoProfile -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" -Mode ha %* +exit /b %ERRORLEVEL% diff --git a/utils/Invoke-ReleasePackage-Single.bat b/utils/Invoke-ReleasePackage-Single.bat new file mode 100644 index 0000000..e2d6d9b --- /dev/null +++ b/utils/Invoke-ReleasePackage-Single.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +pwsh -NoProfile -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" -Mode single %* +exit /b %ERRORLEVEL% diff --git a/utils/Invoke-TestEngine.bat b/utils/Invoke-TestEngine.bat new file mode 100644 index 0000000..7953777 --- /dev/null +++ b/utils/Invoke-TestEngine.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +pwsh -NoProfile -File "%~dp0engines\test\Invoke-TestEngine.ps1" %* +exit /b %ERRORLEVEL% diff --git a/utils/Uninstall-Helm-HA.bat b/utils/Uninstall-Helm-HA.bat new file mode 100644 index 0000000..b782f2b --- /dev/null +++ b/utils/Uninstall-Helm-HA.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +pwsh -NoProfile -File "%~dp0engines\uninstall\Invoke-UninstallEngine.ps1" -Mode ha %* +exit /b %ERRORLEVEL% diff --git a/utils/Uninstall-Helm-Single.bat b/utils/Uninstall-Helm-Single.bat new file mode 100644 index 0000000..b13bbf9 --- /dev/null +++ b/utils/Uninstall-Helm-Single.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +pwsh -NoProfile -File "%~dp0engines\uninstall\Invoke-UninstallEngine.ps1" -Mode single %* +exit /b %ERRORLEVEL% diff --git a/utils/src/Update-RepoUtils.bat b/utils/Update-RepoUtils.bat similarity index 100% rename from utils/src/Update-RepoUtils.bat rename to utils/Update-RepoUtils.bat diff --git a/utils/src/engines/release/Invoke-ReleasePackage.ps1 b/utils/engines/release/Invoke-ReleasePackage.ps1 similarity index 65% rename from utils/src/engines/release/Invoke-ReleasePackage.ps1 rename to utils/engines/release/Invoke-ReleasePackage.ps1 index caf880e..dd59c4f 100644 --- a/utils/src/engines/release/Invoke-ReleasePackage.ps1 +++ b/utils/engines/release/Invoke-ReleasePackage.ps1 @@ -4,23 +4,46 @@ <# .SYNOPSIS Plugin-driven release engine entry script. + +.PARAMETER DryRun + When set, plugins that declare mutatesRemote in Get-PluginMetadata validate only (no registry push, GitHub release, or cluster deploy). + +.PARAMETER Mode + Optional override for HelmSelfDeploy values resolution (single or ha). + When omitted, HelmSelfDeploy.deployMode from scriptSettings.json is used (CI/CD default). + Manual installs: Invoke-ReleasePackage-Single.bat / Invoke-ReleasePackage-HA.bat. #> -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path +[CmdletBinding()] +param( + [switch]$DryRun, + [ValidateSet('single', 'ha')] + [string]$Mode +) + +$ErrorActionPreference = 'Stop' +Set-Location $PSScriptRoot + +$srcDir = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path . (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1') Import-EngineModules -Engine Release -$settings = Get-ScriptSettings -ScriptDir $scriptDir +$settings = Get-ScriptSettings -ScriptDir $PSScriptRoot +$resolveModeParams = @{ Settings = $settings } +if ($PSBoundParameters.ContainsKey('Mode')) { + $resolveModeParams['ModeOverride'] = $Mode +} +$deployMode = Get-DeployModeFromSettings @resolveModeParams $configuredPlugins = Get-ConfiguredPlugins -Settings $settings Write-Log -Level 'STEP' -Message '==================================================' -Write-Log -Level 'STEP' -Message 'RELEASE ENGINE' +Write-Log -Level 'STEP' -Message "RELEASE ENGINE (deploy mode: $deployMode)" Write-Log -Level 'STEP' -Message '==================================================' $plugins = $configuredPlugins -$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $scriptDir -SrcDir $srcDir -Settings $settings +$engineContext = New-EngineContext -Plugins $plugins -ScriptDir $PSScriptRoot -SrcDir $srcDir -Settings $settings -DryRun:$DryRun -DeployMode $deployMode + Write-Log -Level 'OK' -Message 'All pre-flight checks passed!' $sharedPluginSettings = $engineContext @@ -34,15 +57,15 @@ else { for ($pluginIndex = 0; $pluginIndex -lt $plugins.Count; $pluginIndex++) { $plugin = $plugins[$pluginIndex] - if ((Test-IsPublishPlugin -Plugin $plugin) -and -not $releaseStageInitialized) { - if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $scriptDir -WriteLogs:$false) { + if ((Test-IsPublishPlugin -Plugin $plugin -EngineDirectory $PSScriptRoot) -and -not $releaseStageInitialized) { + if (Test-PluginRunnable -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -WriteLogs:$false) { $remainingPlugins = @($plugins[$pluginIndex..($plugins.Count - 1)]) Initialize-ReleaseStageContext -RemainingPlugins $remainingPlugins -SharedSettings $sharedPluginSettings -ArtifactsDirectory $engineContext.artifactsDirectory -Version $engineContext.version $releaseStageInitialized = $true } } - $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $scriptDir -ContinueOnError:$false + $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -ContinueOnError:$false if (-not $pluginSucceeded) { $releaseHadPluginFailures = $true break @@ -52,7 +75,7 @@ else { if (-not $releaseStageInitialized) { $noReleasePluginsLogLevel = if ($engineContext.isNonReleaseBranch) { 'INFO' } else { 'WARN' } - Write-Log -Level $noReleasePluginsLogLevel -Message 'No release-stage initialization ran (no enabled publish plugins reached, or none runnable).' + Write-Log -Level $noReleasePluginsLogLevel -Message 'No release-stage initialization ran (no enabled remote-mutation plugins reached, or none runnable).' } Write-Log -Level 'OK' -Message '==================================================' @@ -65,6 +88,9 @@ elseif ($engineContext.PSObject.Properties.Name -contains 'skipPublishPlugins' - elseif ($engineContext.isNonReleaseBranch) { Write-Log -Level 'OK' -Message 'NON-RELEASE RUN COMPLETE' } +elseif ($engineContext.dryRun) { + Write-Log -Level 'OK' -Message 'DRY RUN COMPLETE' +} else { Write-Log -Level 'OK' -Message 'RELEASE COMPLETE' } diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json new file mode 100644 index 0000000..82fc42c --- /dev/null +++ b/utils/engines/release/scriptSettings.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Release Package Script Settings", + "description": "maksit-webui: npm workspace publish (@maks-it.com/webui-*). Semver from NpmReleaseVersion (src/package.json).", + "plugins": [ + { + "name": "NpmReleaseVersion", + "stageLabel": "build", + "enabled": true, + "packageJsonPath": "..\\..\\..\\src\\package.json", + "syncWorkspaceVersions": true + }, + { + "name": "NpmBuild", + "stageLabel": "build", + "enabled": true, + "workspaceRoot": "..\\..\\..\\src", + "useCi": true, + "buildScript": "build" + }, + { + "name": "ReleasePublishGuard", + "stageLabel": "release", + "enabled": true, + "branches": [ + "main" + ], + "requireExactTagOnHead": true, + "tagVersionMustMatchReleaseVersion": true, + "whenRequirementsNotMet": "skip", + "requireCleanWorkingTree": false, + "ensureTagOnRemote": true, + "remoteName": "origin" + }, + { + "name": "NpmPack", + "stageLabel": "release", + "enabled": true, + "workspaceRoot": "..\\..\\..\\src", + "publishOrder": [ + "@maks-it.com/webui-contracts", + "@maks-it.com/webui-core", + "@maks-it.com/webui-components" + ] + }, + { + "name": "GitHub", + "stageLabel": "release", + "enabled": true, + "githubSecret": "GitHub", + "repository": "https://github.com/MAKS-IT-COM/maksit-webui", + "releaseNotesFile": "..\\..\\..\\CHANGELOG.md", + "releaseTitlePattern": "Release {version}" + }, + { + "name": "NpmPublish", + "stageLabel": "release", + "enabled": true, + "npmSecret": "Npm", + "registry": "https://registry.npmjs.org", + "access": "public", + "workspaceRoot": "..\\..\\..\\src", + "publishOrder": [ + "@maks-it.com/webui-contracts", + "@maks-it.com/webui-core", + "@maks-it.com/webui-components" + ] + } + ] +} diff --git a/utils/src/engines/test/Invoke-TestEngine.ps1 b/utils/engines/test/Invoke-TestEngine.ps1 similarity index 100% rename from utils/src/engines/test/Invoke-TestEngine.ps1 rename to utils/engines/test/Invoke-TestEngine.ps1 diff --git a/utils/src/engines/test/scriptSettings.json b/utils/engines/test/scriptSettings.json similarity index 63% rename from utils/src/engines/test/scriptSettings.json rename to utils/engines/test/scriptSettings.json index cf86787..60cfdfc 100644 --- a/utils/src/engines/test/scriptSettings.json +++ b/utils/engines/test/scriptSettings.json @@ -1,17 +1,17 @@ { "$schema": "https://json-schema.org/draft-07/schema", "title": "Run Tests Script Settings", - "description": "maksit-webui: plugin-driven Jest tests and coverage badges.", + "description": "maksit-webui: Jest tests and coverage badges for README assets.", "paths": { - "badgesDir": "..\\..\\..\\..\\assets\\badges" + "badgesDir": "..\\..\\..\\assets\\badges" }, "plugins": [ { "name": "NpmJestTest", "stageLabel": "test", "enabled": true, - "workspaceRoot": "..\\..\\..\\..\\src", - "testScript": "test", + "workspaceRoot": "..\\..\\..\\src", + "testScript": "test:coverage", "coverageDirectory": "coverage" }, { @@ -25,7 +25,7 @@ "name": "CoverageBadges", "stageLabel": "report", "enabled": true, - "badgesDir": "..\\..\\..\\..\\assets\\badges", + "badgesDir": "..\\..\\..\\assets\\badges", "badges": [ { "name": "coverage-lines.svg", @@ -52,12 +52,5 @@ "red": 0 } } - ], - "_comments": { - "plugins": { - "NpmJestTest": "Runs npm test with Jest coverage in workspaceRoot. Publishes coverage metrics on shared context.", - "QualityGate": "Reads shared context metrics; set coverageThreshold > 0 to enforce minimum line coverage.", - "CoverageBadges": "Writes SVG badges from shared context metrics into badgesDir." - } - } + ] } diff --git a/utils/engines/uninstall/Invoke-UninstallEngine.ps1 b/utils/engines/uninstall/Invoke-UninstallEngine.ps1 new file mode 100644 index 0000000..35ca136 --- /dev/null +++ b/utils/engines/uninstall/Invoke-UninstallEngine.ps1 @@ -0,0 +1,68 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Plugin-driven Helm uninstall engine entry script. + +.PARAMETER Mode + Optional override when inheriting HelmSelfDeploy settings (single or ha). + When omitted, HelmSelfDeploy.deployMode from engines/release/scriptSettings.json is used. +#> + +[CmdletBinding()] +param( + [ValidateSet('single', 'ha')] + [string]$Mode +) + +$ErrorActionPreference = 'Stop' +Set-Location $PSScriptRoot + +$srcDir = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + +. (Join-Path $srcDir 'modules/Engine/Import-EngineModules.ps1') +Import-EngineModules -Engine Test + +$settings = Get-ScriptSettings -ScriptDir $PSScriptRoot +$releaseSettings = Get-ScriptSettings -ScriptDir (Join-Path $PSScriptRoot '..\release') +$resolveModeParams = @{ Settings = $releaseSettings } +if ($PSBoundParameters.ContainsKey('Mode')) { + $resolveModeParams['ModeOverride'] = $Mode +} +$deployMode = Get-DeployModeFromSettings @resolveModeParams +$configuredPlugins = Get-ConfiguredPlugins -Settings $settings + +Write-Log -Level 'STEP' -Message '==================================================' +Write-Log -Level 'STEP' -Message "HELM UNINSTALL ENGINE (deploy mode: $deployMode)" +Write-Log -Level 'STEP' -Message '==================================================' + +$engineContext = New-EngineContext -ScriptDir $PSScriptRoot -SrcDir $srcDir -Settings $settings -DeployMode $deployMode + +if ($configuredPlugins.Count -eq 0) { + Write-Log -Level 'WARN' -Message 'No plugins configured in scriptSettings.json.' + exit 0 +} + +$uninstallHadPluginFailures = $false + +foreach ($plugin in $configuredPlugins) { + $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -EngineDirectory $PSScriptRoot -ContinueOnError:$false + if (-not $pluginSucceeded) { + $uninstallHadPluginFailures = $true + break + } +} + +Write-Log -Level 'OK' -Message '==================================================' +if ($uninstallHadPluginFailures) { + Write-Log -Level 'ERROR' -Message 'UNINSTALL FAILED' +} +else { + Write-Log -Level 'OK' -Message 'UNINSTALL COMPLETE' +} +Write-Log -Level 'OK' -Message '==================================================' + +if ($uninstallHadPluginFailures) { + exit 1 +} diff --git a/utils/src/engines/test/custom/.gitkeep b/utils/engines/uninstall/custom/.gitkeep similarity index 100% rename from utils/src/engines/test/custom/.gitkeep rename to utils/engines/uninstall/custom/.gitkeep diff --git a/utils/engines/uninstall/scriptSettings.json b/utils/engines/uninstall/scriptSettings.json new file mode 100644 index 0000000..813e002 --- /dev/null +++ b/utils/engines/uninstall/scriptSettings.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "title": "Helm Uninstall Script Settings", + "description": "HelmUninstall inherits namespace, charts, and bgpServices from HelmSelfDeploy in engines/release/scriptSettings.json unless inheritFromRelease is false.", + "plugins": [ + { + "name": "HelmUninstall", + "stageLabel": "uninstall", + "enabled": false + } + ] +} + diff --git a/utils/src/modules/ChangelogSupport.psm1 b/utils/modules/ChangelogSupport.psm1 similarity index 100% rename from utils/src/modules/ChangelogSupport.psm1 rename to utils/modules/ChangelogSupport.psm1 diff --git a/utils/modules/ComposeDeploySupport.psm1 b/utils/modules/ComposeDeploySupport.psm1 new file mode 100644 index 0000000..2d5ecc1 --- /dev/null +++ b/utils/modules/ComposeDeploySupport.psm1 @@ -0,0 +1,189 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Shared helpers for DockerCompose and PodmanCompose release plugins. +#> + +$containerEngineSupportModulePath = Join-Path $PSScriptRoot 'ContainerEngineSupport.psm1' +if (Test-Path $containerEngineSupportModulePath -PathType Leaf) { + Import-Module $containerEngineSupportModulePath -Force -Global +} + +function Get-ComposeInvocation { + param( + [Parameter(Mandatory = $false)] + [psobject]$SharedSettings = $null + ) + + if ($null -ne $SharedSettings -and $SharedSettings.PSObject.Properties.Name -contains 'containerEngineProbeCompleted' -and $SharedSettings.containerEngineProbeCompleted) { + return Get-SharedComposeInvocation -SharedSettings $SharedSettings + } + + if (Test-ComposeSubcommand 'docker') { + return @{ Executable = 'docker'; BaseArgs = @('compose'); Engine = 'docker' } + } + + if (Test-ComposeSubcommand 'podman') { + return @{ Executable = 'podman'; BaseArgs = @('compose'); Engine = 'podman' } + } + + if (Get-Command docker-compose -ErrorAction SilentlyContinue) { + return @{ Executable = 'docker-compose'; BaseArgs = @(); Engine = 'docker-compose' } + } + + throw 'Compose is not available. Run ContainerEngineProbe first, or install docker compose / podman compose.' +} + +function Resolve-ComposeProjectDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [Parameter(Mandatory = $true)] + [string]$ComposePath + ) + + $projectDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $ComposePath)) + if (-not (Test-Path $projectDir -PathType Container)) { + throw "Compose project directory not found: $projectDir" + } + + return $projectDir +} + +function Get-ComposeFileArguments { + param( + [Parameter(Mandatory = $true)] + [string[]]$ComposeFiles + ) + + if ($ComposeFiles.Count -eq 0) { + throw 'At least one compose file is required.' + } + + $args = New-Object System.Collections.Generic.List[string] + foreach ($fileName in $ComposeFiles) { + if ([string]::IsNullOrWhiteSpace($fileName)) { + continue + } + + $args.Add('-f') + $args.Add([string]$fileName) + } + + if ($args.Count -eq 0) { + throw 'At least one non-empty compose file name is required.' + } + + return @($args) +} + +function Invoke-ComposeConfigValidation { + param( + [Parameter(Mandatory = $true)] + [string]$ProjectDir, + + [Parameter(Mandatory = $true)] + [string[]]$ComposeFiles, + + [Parameter(Mandatory = $false)] + [psobject]$SharedSettings = $null + ) + + $compose = Get-ComposeInvocation -SharedSettings $SharedSettings + $fileArgs = Get-ComposeFileArguments -ComposeFiles $ComposeFiles + + foreach ($fileName in $ComposeFiles) { + $composeFilePath = Join-Path $ProjectDir $fileName + if (-not (Test-Path $composeFilePath -PathType Leaf)) { + throw "Compose file not found: $composeFilePath" + } + } + + $commandArgs = @($compose.BaseArgs) + $fileArgs + @('config', '--quiet') + Write-Log -Level 'INFO' -Message "Validating Compose project at $ProjectDir ($($ComposeFiles -join ', ')) via $($compose.Engine) ..." + + Push-Location $ProjectDir + try { + $output = & $compose.Executable @commandArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "$($compose.Engine) compose config failed: $($output | Out-String)" + } + } + finally { + Pop-Location + } + + Write-Log -Level 'OK' -Message ' Compose configuration is valid.' +} + +function Invoke-ComposeStackPlugin { + param( + [Parameter(Mandatory = $true)] + $Settings, + + [Parameter(Mandatory = $true)] + [ValidateSet('docker', 'podman')] + [string]$RequiredEngine, + + [Parameter(Mandatory = $true)] + [string]$PluginName + ) + + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'ContainerEngineSupport' -RequiredCommand 'Test-ShouldRunContainerEnginePlugin' + + $pluginSettings = $Settings + $shared = $Settings.context + $scriptDir = [string]$shared.scriptDir + + if (-not (Test-ShouldRunContainerEnginePlugin -RequiredEngine $RequiredEngine -SharedSettings $shared -PluginName $PluginName)) { + return + } + + if ([string]::IsNullOrWhiteSpace($pluginSettings.composePath)) { + throw "$PluginName requires 'composePath' (directory with docker-compose.yml, relative to engines/release)." + } + + $composeFiles = @('docker-compose.yml', 'docker-compose.override.yml') + if ($pluginSettings.PSObject.Properties.Name -contains 'composeFiles' -and $null -ne $pluginSettings.composeFiles) { + $composeFiles = @($pluginSettings.composeFiles | ForEach-Object { [string]$_ }) + } + + $projectDir = Resolve-ComposeProjectDirectory -ScriptDir $scriptDir -ComposePath ([string]$pluginSettings.composePath) + Invoke-ComposeConfigValidation -ProjectDir $projectDir -ComposeFiles $composeFiles -SharedSettings $shared + + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared + if ($dryRun) { + Write-Log -Level 'INFO' -Message 'Dry run: compose up/build skipped.' + return + } + + $compose = Get-ComposeInvocation -SharedSettings $shared + $fileArgs = Get-ComposeFileArguments -ComposeFiles $composeFiles + $upArgs = @($compose.BaseArgs) + $fileArgs + @('up', '--build', '-d') + + Write-Log -Level 'STEP' -Message "Starting Compose stack at $projectDir via $($compose.Engine) ..." + Push-Location $projectDir + try { + $output = & $compose.Executable @upArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "$($compose.Engine) compose up failed: $($output | Out-String)" + } + } + finally { + Pop-Location + } + + Write-Log -Level 'OK' -Message " Compose stack started via $($compose.Engine)." + $shared | Add-Member -NotePropertyName composeDeployCompleted -NotePropertyValue $true -Force +} + +Export-ModuleMember -Function ` + Get-ComposeInvocation, ` + Resolve-ComposeProjectDirectory, ` + Get-ComposeFileArguments, ` + Invoke-ComposeConfigValidation, ` + Invoke-ComposeStackPlugin diff --git a/utils/modules/ContainerEngineSupport.psm1 b/utils/modules/ContainerEngineSupport.psm1 new file mode 100644 index 0000000..095a688 --- /dev/null +++ b/utils/modules/ContainerEngineSupport.psm1 @@ -0,0 +1,320 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Container engine probe, shared pipeline state, and CLI helpers. + +.DESCRIPTION + ContainerEngineProbe sets mandatory shared context fields. Engine-specific plugins call + Test-ShouldRunContainerEnginePlugin before running (safe skip when another engine is active). +#> + +function Get-ContainerRemoteConnection { + $containerHost = [Environment]::GetEnvironmentVariable('CONTAINER_HOST') + $dockerHost = [Environment]::GetEnvironmentVariable('DOCKER_HOST') + + return @{ + ContainerHost = if ([string]::IsNullOrWhiteSpace($containerHost)) { $null } else { $containerHost.Trim() } + DockerHost = if ([string]::IsNullOrWhiteSpace($dockerHost)) { $null } else { $dockerHost.Trim() } + } +} + +function Test-ContainerEngineCli { + param( + [Parameter(Mandatory = $true)] + [string]$Executable + ) + + if (-not (Get-Command $Executable -ErrorAction SilentlyContinue)) { + return $false + } + + $null = & $Executable version 2>$null + return $LASTEXITCODE -eq 0 +} + +function Test-ComposeSubcommand { + param( + [Parameter(Mandatory = $true)] + [string]$Executable + ) + + if (-not (Get-Command $Executable -ErrorAction SilentlyContinue)) { + return $false + } + + $null = & $Executable compose version 2>$null + return $LASTEXITCODE -eq 0 +} + +function Resolve-ActiveContainerEngineState { + $remote = Get-ContainerRemoteConnection + $forced = [Environment]::GetEnvironmentVariable('MAKSIT_CONTAINER_ENGINE') + if (-not [string]::IsNullOrWhiteSpace($forced)) { + $forced = $forced.Trim().ToLowerInvariant() + } + + $dockerEngineAvailable = Test-ContainerEngineCli 'docker' + $podmanEngineAvailable = Test-ContainerEngineCli 'podman' + + $invocation = $null + if ($forced -eq 'docker') { + if (-not $dockerEngineAvailable) { + throw 'MAKSIT_CONTAINER_ENGINE=docker but the docker CLI is not available.' + } + + $invocation = New-ContainerEngineInvocation -Executable 'docker' -Engine 'docker' -Remote $remote + } + elseif ($forced -eq 'podman') { + if (-not $podmanEngineAvailable) { + throw 'MAKSIT_CONTAINER_ENGINE=podman but the podman CLI is not available.' + } + + $invocation = New-ContainerEngineInvocation -Executable 'podman' -Engine 'podman' -Remote $remote + } + elseif ($remote.ContainerHost) { + if ($podmanEngineAvailable) { + $invocation = New-ContainerEngineInvocation -Executable 'podman' -Engine 'podman' -Remote $remote -UsesRemote $true + } + elseif ($dockerEngineAvailable) { + $invocation = New-ContainerEngineInvocation -Executable 'docker' -Engine 'docker' -Remote $remote -UsesRemote $true + } + else { + throw 'CONTAINER_HOST is set but neither podman nor docker CLI is on PATH.' + } + } + elseif ($dockerEngineAvailable) { + $invocation = New-ContainerEngineInvocation -Executable 'docker' -Engine 'docker' -Remote $remote + } + elseif ($podmanEngineAvailable) { + $invocation = New-ContainerEngineInvocation -Executable 'podman' -Engine 'podman' -Remote $remote + } + elseif ($remote.DockerHost -and $dockerEngineAvailable) { + $invocation = New-ContainerEngineInvocation -Executable 'docker' -Engine 'docker' -Remote $remote -UsesRemote $true + } + + if ($null -eq $invocation) { + throw 'No container engine found. Install Docker Desktop or Podman locally, or set CONTAINER_HOST to a remote Podman API.' + } + + $composeInvocation = Resolve-ComposeInvocationForEngine -Engine $invocation.Engine -DockerEngineAvailable $dockerEngineAvailable -PodmanEngineAvailable $podmanEngineAvailable + + return @{ + ActiveContainerEngine = [string]$invocation.Engine + DockerEngineAvailable = [bool]$dockerEngineAvailable + PodmanEngineAvailable = [bool]$podmanEngineAvailable + ContainerExecutable = [string]$invocation.Executable + ContainerRemoteLabel = $invocation.RemoteLabel + ComposeExecutable = [string]$composeInvocation.Executable + ComposeBaseArgs = @($composeInvocation.BaseArgs) + ComposeEngine = [string]$composeInvocation.Engine + } +} + +function Resolve-ComposeInvocationForEngine { + param( + [Parameter(Mandatory = $true)] + [string]$Engine, + + [Parameter(Mandatory = $true)] + [bool]$DockerEngineAvailable, + + [Parameter(Mandatory = $true)] + [bool]$PodmanEngineAvailable + ) + + if ($Engine -eq 'docker' -and $DockerEngineAvailable -and (Test-ComposeSubcommand 'docker')) { + return @{ Executable = 'docker'; BaseArgs = @('compose'); Engine = 'docker' } + } + + if ($Engine -eq 'podman' -and $PodmanEngineAvailable -and (Test-ComposeSubcommand 'podman')) { + return @{ Executable = 'podman'; BaseArgs = @('compose'); Engine = 'podman' } + } + + if ($Engine -eq 'docker' -and (Get-Command docker-compose -ErrorAction SilentlyContinue)) { + return @{ Executable = 'docker-compose'; BaseArgs = @(); Engine = 'docker-compose' } + } + + throw "Compose is not available for active engine '$Engine'. Install docker compose or podman compose." +} + +function New-ContainerEngineInvocation { + param( + [Parameter(Mandatory = $true)] + [string]$Executable, + + [Parameter(Mandatory = $true)] + [string]$Engine, + + [Parameter(Mandatory = $true)] + [hashtable]$Remote, + + [bool]$UsesRemote = $false + ) + + $remoteLabel = $null + if ($Remote.ContainerHost) { + $remoteLabel = "CONTAINER_HOST=$($Remote.ContainerHost)" + } + elseif ($Remote.DockerHost) { + $remoteLabel = "DOCKER_HOST=$($Remote.DockerHost)" + } + + return @{ + Executable = $Executable + Engine = $Engine + UsesRemote = $UsesRemote -or [bool]$Remote.ContainerHost -or [bool]$Remote.DockerHost + RemoteLabel = $remoteLabel + } +} + +function Set-ContainerEngineSharedState { + param( + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [hashtable]$State + ) + + $SharedSettings | Add-Member -NotePropertyName containerEngineProbeCompleted -NotePropertyValue $true -Force + $SharedSettings | Add-Member -NotePropertyName activeContainerEngine -NotePropertyValue $State.ActiveContainerEngine -Force + $SharedSettings | Add-Member -NotePropertyName dockerEngineAvailable -NotePropertyValue $State.DockerEngineAvailable -Force + $SharedSettings | Add-Member -NotePropertyName podmanEngineAvailable -NotePropertyValue $State.PodmanEngineAvailable -Force + $SharedSettings | Add-Member -NotePropertyName containerExecutable -NotePropertyValue $State.ContainerExecutable -Force + + if ($State.ContainerRemoteLabel) { + $SharedSettings | Add-Member -NotePropertyName containerRemoteLabel -NotePropertyValue $State.ContainerRemoteLabel -Force + } + + $SharedSettings | Add-Member -NotePropertyName composeExecutable -NotePropertyValue $State.ComposeExecutable -Force + $SharedSettings | Add-Member -NotePropertyName composeBaseArgs -NotePropertyValue $State.ComposeBaseArgs -Force + $SharedSettings | Add-Member -NotePropertyName composeEngine -NotePropertyValue $State.ComposeEngine -Force +} + +function Assert-ContainerEngineProbeCompleted { + param( + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + if (-not ($SharedSettings.PSObject.Properties.Name -contains 'containerEngineProbeCompleted') -or -not $SharedSettings.containerEngineProbeCompleted) { + throw 'ContainerEngineProbe must run before container engine plugins. Add ContainerEngineProbe as the first enabled plugin in scriptSettings.json.' + } +} + +function Test-ShouldRunContainerEnginePlugin { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('docker', 'podman')] + [string]$RequiredEngine, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings, + + [Parameter(Mandatory = $true)] + [string]$PluginName + ) + + Assert-ContainerEngineProbeCompleted -SharedSettings $SharedSettings + + if ([string]$SharedSettings.activeContainerEngine -eq $RequiredEngine) { + return $true + } + + $engineAvailable = if ($RequiredEngine -eq 'docker') { + [bool]$SharedSettings.dockerEngineAvailable + } + else { + [bool]$SharedSettings.podmanEngineAvailable + } + + Write-Log -Level 'INFO' -Message "Skipping $PluginName (requires $RequiredEngine; active engine: $($SharedSettings.activeContainerEngine); ${RequiredEngine} CLI available: $engineAvailable)." + return $false +} + +function Get-SharedContainerEngineInvocation { + param( + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + Assert-ContainerEngineProbeCompleted -SharedSettings $SharedSettings + + return @{ + Executable = [string]$SharedSettings.containerExecutable + Engine = [string]$SharedSettings.activeContainerEngine + RemoteLabel = if ($SharedSettings.PSObject.Properties.Name -contains 'containerRemoteLabel') { [string]$SharedSettings.containerRemoteLabel } else { $null } + } +} + +function Get-SharedComposeInvocation { + param( + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + Assert-ContainerEngineProbeCompleted -SharedSettings $SharedSettings + + return @{ + Executable = [string]$SharedSettings.composeExecutable + BaseArgs = @($SharedSettings.composeBaseArgs) + Engine = [string]$SharedSettings.composeEngine + } +} + +function Get-ContainerEngineDisplayName { + param( + [Parameter(Mandatory = $true)] + [hashtable]$Engine + ) + + if ($Engine.RemoteLabel) { + return "$($Engine.Engine) ($($Engine.RemoteLabel))" + } + + return [string]$Engine.Engine +} + +function Invoke-ContainerEngine { + param( + [Parameter(Mandatory = $true)] + [hashtable]$Engine, + + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$ArgumentList + ) + + & $Engine.Executable @ArgumentList + return $LASTEXITCODE +} + +function Invoke-ContainerEngineWithInput { + param( + [Parameter(Mandatory = $true)] + [hashtable]$Engine, + + [Parameter(Mandatory = $true)] + [string]$InputObject, + + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$ArgumentList + ) + + $InputObject | & $Engine.Executable @ArgumentList 2>&1 +} + +Export-ModuleMember -Function ` + Get-ContainerRemoteConnection, ` + Resolve-ActiveContainerEngineState, ` + Set-ContainerEngineSharedState, ` + Assert-ContainerEngineProbeCompleted, ` + Test-ShouldRunContainerEnginePlugin, ` + Get-SharedContainerEngineInvocation, ` + Get-SharedComposeInvocation, ` + Get-ContainerEngineDisplayName, ` + Invoke-ContainerEngine, ` + Invoke-ContainerEngineWithInput, ` + Test-ContainerEngineCli, ` + Test-ComposeSubcommand diff --git a/utils/src/plugins/DotNet/DotNetDockerPush.psm1 b/utils/modules/ContainerImagePushSupport.psm1 similarity index 53% rename from utils/src/plugins/DotNet/DotNetDockerPush.psm1 rename to utils/modules/ContainerImagePushSupport.psm1 index 6e6855a..be5740b 100644 --- a/utils/src/plugins/DotNet/DotNetDockerPush.psm1 +++ b/utils/modules/ContainerImagePushSupport.psm1 @@ -3,52 +3,15 @@ <# .SYNOPSIS - .NET Docker publish plugin — build and push container images for .NET apps. - -.DESCRIPTION - Logs in with credentials from a Base64-encoded username:password environment variable, - builds each configured image once, then tags and pushes: bare semver from DotNetReleaseVersion - (e.g. 3.3.4), v-prefixed alias (v3.3.4) when different, optional exact shared.tag if it differs, - and optional latest. - - Release image tags align with shared.version (same bare semver as Helm chart/OCI when used together); not from Chart.yaml. + Shared container image build/push logic for DockerImagePush and PodmanImagePush. #> -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } +$containerEngineSupportModulePath = Join-Path $PSScriptRoot 'ContainerEngineSupport.psm1' +if (Test-Path $containerEngineSupportModulePath -PathType Leaf) { + Import-Module $containerEngineSupportModulePath -Force -Global } -function Get-RegistryCredentialsFromEnv { - param( - [Parameter(Mandatory = $true)] - [string]$EnvVarName - ) - - $raw = [Environment]::GetEnvironmentVariable($EnvVarName) - if ([string]::IsNullOrWhiteSpace($raw)) { - throw "Environment variable '$EnvVarName' is not set." - } - - try { - $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) - } - catch { - throw "Failed to decode '$EnvVarName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" - } - - $parts = $decoded -split ':', 2 - if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { - throw "Decoded '$EnvVarName' must be in the form 'username:password'." - } - - return @{ User = $parts[0]; Password = $parts[1] } -} - -function Set-EnvVersionValue { +function Set-ContainerImageEnvVersionValue { param( [Parameter(Mandatory = $true)] [string]$FilePath, @@ -69,48 +32,52 @@ function Set-EnvVersionValue { Set-Content -LiteralPath $FilePath -Value $content -NoNewline } -function Invoke-Plugin { +function Invoke-ContainerImagePush { param( [Parameter(Mandatory = $true)] - $Settings + $Settings, + + [Parameter(Mandatory = $true)] + [string]$PluginDisplayName ) - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'ContainerEngineSupport' -RequiredCommand 'Get-SharedContainerEngineInvocation' $pluginSettings = $Settings $shared = $Settings.context - - Assert-Command docker + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared + $engine = Get-SharedContainerEngineInvocation -SharedSettings $shared + $engineName = Get-ContainerEngineDisplayName -Engine $engine if ([string]::IsNullOrWhiteSpace($pluginSettings.registryUrl)) { - throw "DotNetDockerPush plugin requires 'registryUrl' (registry hostname, no scheme)." + throw "$PluginDisplayName requires 'registryUrl' (registry hostname, no scheme)." } - if ([string]::IsNullOrWhiteSpace($pluginSettings.credentialsEnvVar)) { - throw "DotNetDockerPush plugin requires 'credentialsEnvVar' (name of env var holding base64 username:password)." + $containerRegistrySecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'containerRegistrySecret' + if ([string]::IsNullOrWhiteSpace($containerRegistrySecret)) { + throw "$PluginDisplayName requires 'containerRegistrySecret' (logical secret name, e.g. ContainerRegistry)." } if ([string]::IsNullOrWhiteSpace($pluginSettings.projectName)) { - throw "DotNetDockerPush plugin requires 'projectName' (image path segment after registry)." + throw "$PluginDisplayName requires 'projectName' (image path segment after registry)." } if ([string]::IsNullOrWhiteSpace($pluginSettings.contextPath)) { - throw "DotNetDockerPush plugin requires 'contextPath' (Docker build context, relative to engines/release folder)." + throw "$PluginDisplayName requires 'contextPath' (build context, relative to engines/release folder)." } if (-not $pluginSettings.images -or @($pluginSettings.images).Count -eq 0) { - throw "DotNetDockerPush plugin requires a non-empty 'images' array with 'service' and 'dockerfile' per entry." + throw "$PluginDisplayName 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" + throw "Build 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)) { @@ -120,9 +87,37 @@ function Invoke-Plugin { $bareVersion = ([string]$shared.tag).Trim() -replace '^[vV]', '' } if ([string]::IsNullOrWhiteSpace($bareVersion)) { - throw "DotNetDockerPush: could not derive version tag (need shared.version from DotNetReleaseVersion or shared.tag)." + throw "${PluginDisplayName}: could not derive version tag (need shared.version from a version plugin or shared.tag)." } + if ($dryRun) { + foreach ($img in @($pluginSettings.images)) { + if ($null -eq $img.service -or $null -eq $img.dockerfile) { + throw "Each images[] entry must define 'service' and 'dockerfile'." + } + + $service = [string]$img.service + $dockerfileRel = [string]$img.dockerfile + $imgContextPath = $contextPath + if ($img.PSObject.Properties.Name -contains 'contextPath' -and -not [string]::IsNullOrWhiteSpace([string]$img.contextPath)) { + $imgContextPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$img.contextPath))) + } + + $dockerfilePath = [System.IO.Path]::GetFullPath((Join-Path $imgContextPath $dockerfileRel)) + if (-not (Test-Path $dockerfilePath -PathType Leaf)) { + throw "Dockerfile not found: $dockerfilePath" + } + + $plannedRef = "$registryUrl/$($pluginSettings.projectName)/${service}:$bareVersion" + Write-Log -Level 'INFO' -Message "Dry run: would build and push $plannedRef via $engineName (dockerfile: $dockerfilePath)" + } + + Write-Log -Level 'OK' -Message " $PluginDisplayName dry run completed." + return + } + + $creds = Get-RegistryCredentialsFromRuntime -SecretName $containerRegistrySecret -SharedSettings $shared + $imageTags = New-Object System.Collections.Generic.List[string] function Add-ImageTag([System.Collections.Generic.List[string]]$List, [string]$Tag) { if ([string]::IsNullOrWhiteSpace($Tag)) { return } @@ -138,10 +133,10 @@ function Invoke-Plugin { 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 + Write-Log -Level 'STEP' -Message "Container login to $registryUrl via $engineName..." + $loginResult = Invoke-ContainerEngineWithInput -Engine $engine -InputObject $creds.Password login $registryUrl -u $creds.User --password-stdin if ($LASTEXITCODE -ne 0 -or ($loginResult -notmatch 'Login Succeeded')) { - throw "Docker login failed for ${registryUrl}: $loginResult" + throw "Container login failed for ${registryUrl}: $loginResult" } try { @@ -157,7 +152,7 @@ function Invoke-Plugin { if ($img.PSObject.Properties.Name -contains 'contextPath' -and -not [string]::IsNullOrWhiteSpace([string]$img.contextPath)) { $imgContextPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ([string]$img.contextPath))) if (-not (Test-Path $imgContextPath -PathType Container)) { - throw "Docker context directory not found for image '$service': $imgContextPath" + throw "Build context directory not found for image '$service': $imgContextPath" } } @@ -182,7 +177,7 @@ function Invoke-Plugin { $backupPath = "$envFilePath.repoutils.bak" Copy-Item -LiteralPath $envFilePath -Destination $backupPath -Force $versionEnvFiles += [pscustomobject]@{ - FilePath = $envFilePath + FilePath = $envFilePath BackupPath = $backupPath } } @@ -190,33 +185,33 @@ function Invoke-Plugin { try { foreach ($envFile in $versionEnvFiles) { - Write-Log -Level "INFO" -Message "Temporarily setting VITE_APP_VERSION=$bareVersion in $($envFile.FilePath)" - Set-EnvVersionValue -FilePath $envFile.FilePath -Version $bareVersion + Write-Log -Level 'INFO' -Message "Temporarily setting VITE_APP_VERSION=$bareVersion in $($envFile.FilePath)" + Set-ContainerImageEnvVersionValue -FilePath $envFile.FilePath -Version $bareVersion } $primaryRef = "${baseName}:$($imageTags[0])" - Write-Log -Level "STEP" -Message "Building $primaryRef ..." - docker build -t $primaryRef -f $dockerfilePath $imgContextPath - if ($LASTEXITCODE -ne 0) { - throw "Docker build failed for $primaryRef" + Write-Log -Level 'STEP' -Message "Building $primaryRef via $engineName ..." + $buildExitCode = Invoke-ContainerEngine -Engine $engine build -t $primaryRef -f $dockerfilePath $imgContextPath + if ($buildExitCode -ne 0) { + throw "Container build failed for $primaryRef" } - Write-Log -Level "STEP" -Message "Pushing $primaryRef ..." - docker push $primaryRef - if ($LASTEXITCODE -ne 0) { - throw "Docker push failed for $primaryRef" + Write-Log -Level 'STEP' -Message "Pushing $primaryRef via $engineName ..." + $pushExitCode = Invoke-ContainerEngine -Engine $engine push $primaryRef + if ($pushExitCode -ne 0) { + throw "Container 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" + Write-Log -Level 'STEP' -Message "Tagging and pushing $aliasRef via $engineName ..." + $tagExitCode = Invoke-ContainerEngine -Engine $engine tag $primaryRef $aliasRef + if ($tagExitCode -ne 0) { + throw "Container tag failed: $primaryRef -> $aliasRef" } - docker push $aliasRef - if ($LASTEXITCODE -ne 0) { - throw "Docker push failed for $aliasRef" + $aliasPushExitCode = Invoke-ContainerEngine -Engine $engine push $aliasRef + if ($aliasPushExitCode -ne 0) { + throw "Container push failed for $aliasRef" } } } @@ -235,11 +230,11 @@ function Invoke-Plugin { } } finally { - docker logout $registryUrl 2>&1 | Out-Null + $null = Invoke-ContainerEngine -Engine $engine logout $registryUrl } - Write-Log -Level "OK" -Message " Docker push completed." + Write-Log -Level 'OK' -Message " $PluginDisplayName completed." $shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force } -Export-ModuleMember -Function Invoke-Plugin +Export-ModuleMember -Function Invoke-ContainerImagePush diff --git a/utils/src/modules/Engine/EngineContext.psm1 b/utils/modules/Engine/EngineContext.psm1 similarity index 67% rename from utils/src/modules/Engine/EngineContext.psm1 rename to utils/modules/Engine/EngineContext.psm1 index 9d397d1..19ad750 100644 --- a/utils/src/modules/Engine/EngineContext.psm1 +++ b/utils/modules/Engine/EngineContext.psm1 @@ -9,6 +9,7 @@ Used by New-EngineContext and version plugins: - DotNetReleaseVersion plugin -> projectFiles (.csproj ) - NpmReleaseVersion plugin -> packageJsonPath (package.json version) + - FileReleaseVersion plugin -> versionFilePath (repo-root VERSION file) #> if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) { @@ -190,6 +191,72 @@ function Resolve-NpmReleaseVersion { } } +function Get-VersionFileSemver { + param( + [Parameter(Mandatory = $true)] + [string]$VersionFilePath + ) + + if (-not (Test-Path $VersionFilePath -PathType Leaf)) { + Write-Error "FileReleaseVersion: VERSION file not found at: $VersionFilePath" + exit 1 + } + + $version = (Get-Content -Path $VersionFilePath -Raw -Encoding UTF8).Trim() + if ([string]::IsNullOrWhiteSpace($version)) { + Write-Error "FileReleaseVersion: VERSION file is empty at: $VersionFilePath" + exit 1 + } + + $version = $version -replace '^[vV]', '' + if ($version -notmatch '^\d+\.\d+\.\d+') { + Write-Error "FileReleaseVersion: version '$version' in '$VersionFilePath' is not a valid semver." + exit 1 + } + + return $version +} + +function Resolve-FileReleaseVersion { + param( + [Parameter(Mandatory = $true)] + [object[]]$Plugins, + + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + $releaseVersionPlugin = @($Plugins | Where-Object { $_.name -eq 'FileReleaseVersion' } | Select-Object -First 1) + if ($releaseVersionPlugin.Count -eq 0 -or $null -eq $releaseVersionPlugin[0]) { + Write-Error "Configure a FileReleaseVersion plugin in scriptSettings.json with versionFilePath." + exit 1 + } + + $releaseVersionSettings = $releaseVersionPlugin[0] + $versionFileSetting = if ($releaseVersionSettings.versionFilePath) { + $releaseVersionSettings.versionFilePath + } + else { + '..\\..\\..\\VERSION' + } + + $versionFilePaths = @(Resolve-RelativePaths -Value $versionFileSetting -BasePath $ScriptDir) + if ($versionFilePaths.Count -eq 0) { + Write-Error "Configure release version via FileReleaseVersion.versionFilePath (repo-root VERSION file)." + exit 1 + } + + $versionFilePath = $versionFilePaths[0] + Write-Log -Level "INFO" -Message "Reading version from VERSION file (versionFilePath)..." + $version = Get-VersionFileSemver -VersionFilePath $versionFilePath + Write-Log -Level "OK" -Message " $([System.IO.Path]::GetFileName($versionFilePath)): $version" + + return [pscustomobject]@{ + version = $version + source = 'FileReleaseVersion' + } +} + function Resolve-ReleaseVersion { param( [Parameter(Mandatory = $true)] @@ -201,9 +268,15 @@ function Resolve-ReleaseVersion { $dotnetPlugin = @($Plugins | Where-Object { $_.name -eq 'DotNetReleaseVersion' -and $_.enabled -ne $false }) $npmPlugin = @($Plugins | Where-Object { $_.name -eq 'NpmReleaseVersion' -and $_.enabled -ne $false }) + $filePlugin = @($Plugins | Where-Object { $_.name -eq 'FileReleaseVersion' -and $_.enabled -ne $false }) - if ($dotnetPlugin.Count -gt 0 -and $npmPlugin.Count -gt 0) { - Write-Error "Configure only one release version plugin: DotNetReleaseVersion or NpmReleaseVersion, not both." + $enabledVersionPlugins = @() + if ($dotnetPlugin.Count -gt 0) { $enabledVersionPlugins += 'DotNetReleaseVersion' } + if ($npmPlugin.Count -gt 0) { $enabledVersionPlugins += 'NpmReleaseVersion' } + if ($filePlugin.Count -gt 0) { $enabledVersionPlugins += 'FileReleaseVersion' } + + if ($enabledVersionPlugins.Count -gt 1) { + Write-Error "Configure only one release version plugin: $($enabledVersionPlugins -join ', ')." exit 1 } @@ -215,11 +288,15 @@ function Resolve-ReleaseVersion { return Resolve-NpmReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir } - Write-Error "Configure a DotNetReleaseVersion plugin (projectFiles) or NpmReleaseVersion plugin (packageJsonPath) in scriptSettings.json." + if ($filePlugin.Count -gt 0) { + return Resolve-FileReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir + } + + Write-Error "Configure a DotNetReleaseVersion (projectFiles), NpmReleaseVersion (packageJsonPath), or FileReleaseVersion (versionFilePath) plugin in scriptSettings.json." exit 1 } -Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Resolve-RelativePaths, Resolve-DotNetReleaseVersion, Resolve-NpmReleaseVersion, Resolve-ReleaseVersion +Export-ModuleMember -Function Get-CsprojPropertyValue, Get-CsprojVersions, Get-VersionFileSemver, Resolve-RelativePaths, Resolve-DotNetReleaseVersion, Resolve-NpmReleaseVersion, Resolve-FileReleaseVersion, Resolve-ReleaseVersion diff --git a/utils/src/modules/Engine/Import-EngineModules.ps1 b/utils/modules/Engine/Import-EngineModules.ps1 similarity index 100% rename from utils/src/modules/Engine/Import-EngineModules.ps1 rename to utils/modules/Engine/Import-EngineModules.ps1 diff --git a/utils/src/modules/Engine/PluginSupport.psm1 b/utils/modules/Engine/PluginSupport.psm1 similarity index 55% rename from utils/src/modules/Engine/PluginSupport.psm1 rename to utils/modules/Engine/PluginSupport.psm1 index 30371ea..22e5082 100644 --- a/utils/src/modules/Engine/PluginSupport.psm1 +++ b/utils/modules/Engine/PluginSupport.psm1 @@ -117,17 +117,265 @@ function Test-PluginAllowedOnBranch { return $allowedBranches -contains $CurrentBranch } -function Test-IsPublishPlugin { +function Get-MaksitOrchestrator { + <# + .SYNOPSIS + Returns Compose/Kubernetes when MAKSIT_ORCHESTRATOR is set; otherwise $null (local dev — all plugins run). + #> + $raw = [Environment]::GetEnvironmentVariable('MAKSIT_ORCHESTRATOR') + if ([string]::IsNullOrWhiteSpace($raw)) { + return $null + } + + switch ($raw.Trim().ToLowerInvariant()) { + 'compose' { return 'Compose' } + 'kubernetes' { return 'Kubernetes' } + 'k8s' { return 'Kubernetes' } + default { return $raw.Trim() } + } +} + +function Get-PluginMetadataObject { param( [Parameter(Mandatory = $true)] - $Plugin + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory + ) + + $modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $modulePath -PathType Leaf)) { + return $null + } + + try { + $moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop + $metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue + if (-not $metadataCommand) { + return $null + } + + return & $metadataCommand + } + catch { + return $null + } +} + +function Test-PluginSupportsOrchestrator { + <# + .SYNOPSIS + When MAKSIT_ORCHESTRATOR is unset (local dev), returns $true for every plugin. + When set (CI/ContainerBuilder), skips plugins whose orchestrators list excludes the active profile. + #> + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [string]$EngineDirectory, + + [Parameter(Mandatory = $false)] + [bool]$WriteLogs = $true + ) + + $orchestrator = Get-MaksitOrchestrator + if ([string]::IsNullOrWhiteSpace($orchestrator)) { + return $true + } + + $allowed = @() + if ($Plugin.PSObject.Properties.Name -contains 'orchestrators' -and $Plugin.orchestrators) { + $allowed = @($Plugin.orchestrators | ForEach-Object { ([string]$_).Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + else { + $metadata = Get-PluginMetadataObject -Plugin $Plugin -EngineDirectory $EngineDirectory + if ($null -ne $metadata -and $metadata.PSObject.Properties.Name -contains 'orchestrators' -and $metadata.orchestrators) { + $allowed = @($metadata.orchestrators | ForEach-Object { ([string]$_).Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + } + + if ($allowed.Count -eq 0) { + return $true + } + + if ($orchestrator -in $allowed) { + return $true + } + + if ($WriteLogs) { + Write-Log -Level 'INFO' -Message "Skipping plugin '$($Plugin.name)' (requires orchestrator in: $($allowed -join ', '); active: $orchestrator)." + } + + return $false +} + +function Test-PluginMutatesRemote { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $false)] + [string]$EngineDirectory ) if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) { return $false } - return @('GitHub', 'DotNetNuGet', 'DotNetDockerPush', 'DotNetHelmPush', 'NpmPublish') -contains ([string]$Plugin.name) + if ([string]::IsNullOrWhiteSpace($EngineDirectory)) { + if ($Plugin.PSObject.Properties.Name -contains 'context' -and $null -ne $Plugin.context -and $Plugin.context.scriptDir) { + $EngineDirectory = [string]$Plugin.context.scriptDir + } + elseif ($Plugin.PSObject.Properties.Name -contains 'scriptDir' -and -not [string]::IsNullOrWhiteSpace([string]$Plugin.scriptDir)) { + $EngineDirectory = [string]$Plugin.scriptDir + } + } + + if ([string]::IsNullOrWhiteSpace($EngineDirectory)) { + return $false + } + + $modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory + if (-not (Test-Path $modulePath -PathType Leaf)) { + return $false + } + + try { + $moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop + $metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue + if (-not $metadataCommand) { + return $false + } + + $metadata = & $metadataCommand + if ($null -eq $metadata) { + return $false + } + + if ($metadata.PSObject.Properties.Name -contains 'mutatesRemote') { + return [bool]$metadata.mutatesRemote + } + } + catch { + return $false + } + + return $false +} + +function Get-SecretEnvironmentValue { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + return [Environment]::GetEnvironmentVariable($Name) +} + +function Resolve-PluginSecretName { + param( + [Parameter(Mandatory = $true)] + $PluginSettings, + + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + if ($PluginSettings.PSObject.Properties.Name -contains $PropertyName) { + $value = [string]$PluginSettings.$PropertyName + if (-not [string]::IsNullOrWhiteSpace($value)) { + return $value.Trim() + } + } + + return $null +} + +function Get-RegistryCredentialsFromRuntime { + param( + [Parameter(Mandatory = $true)] + [string]$SecretName, + + [Parameter(Mandatory = $false)] + [psobject]$SharedSettings + ) + + $raw = Get-SecretEnvironmentValue -Name $SecretName + if ([string]::IsNullOrWhiteSpace($raw)) { + throw "Environment variable '$SecretName' is not set." + } + + try { + $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) + } + catch { + throw "Failed to decode '$SecretName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" + } + + $parts = $decoded -split ':', 2 + if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { + throw "Decoded '$SecretName' must be in the form 'username:password'." + } + + return @{ User = $parts[0]; Password = $parts[1] } +} + +function Get-EngineDryRun { + param( + [Parameter(Mandatory = $false)] + [psobject]$Settings + ) + + $envVal = [Environment]::GetEnvironmentVariable('MAKSIT_REPOUTILS_DRY_RUN') + if (-not [string]::IsNullOrWhiteSpace($envVal)) { + return @('1', 'true', 'yes') -contains $envVal.Trim().ToLowerInvariant() + } + + if ($Settings -and $Settings.PSObject.Properties['dryRun'] -and $null -ne $Settings.dryRun) { + return [bool]$Settings.dryRun + } + + return $false +} + +function Test-EngineDryRun { + param( + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + if ($SharedSettings.PSObject.Properties.Name -contains 'dryRun') { + return [bool]$SharedSettings.dryRun + } + + return $false +} + +function Test-PluginSkipsRemoteMutation { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $true)] + [psobject]$SharedSettings + ) + + $engineDirectory = [string]$SharedSettings.scriptDir + return (Test-EngineDryRun -SharedSettings $SharedSettings) -and (Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $engineDirectory) +} + +function Test-IsPublishPlugin { + param( + [Parameter(Mandatory = $true)] + $Plugin, + + [Parameter(Mandatory = $false)] + [string]$EngineDirectory + ) + + return Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $EngineDirectory } function Get-PluginSettingValue { @@ -266,6 +514,9 @@ function Resolve-PluginModulePath { $candidatePaths = @( (Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName), (Join-Path (Join-Path $pluginsRoot "Platform") $pluginFileName), + (Join-Path (Join-Path $pluginsRoot "Docker") $pluginFileName), + (Join-Path (Join-Path $pluginsRoot "Podman") $pluginFileName), + (Join-Path (Join-Path $pluginsRoot "Helm") $pluginFileName), (Join-Path (Join-Path $pluginsRoot "DotNet") $pluginFileName), (Join-Path (Join-Path $pluginsRoot "Npm") $pluginFileName) ) @@ -365,6 +616,10 @@ function Invoke-ConfiguredPlugin { return $true } + if (-not (Test-PluginSupportsOrchestrator -Plugin $Plugin -EngineDirectory $EngineDirectory -WriteLogs:$true)) { + return $true + } + $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..." @@ -383,4 +638,4 @@ function Invoke-ConfiguredPlugin { } } -Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin +Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-MaksitOrchestrator, Get-PluginMetadataObject, Test-PluginSupportsOrchestrator, Test-PluginMutatesRemote, Resolve-PluginSecretName, Get-SecretEnvironmentValue, Get-RegistryCredentialsFromRuntime, Get-EngineDryRun, Test-EngineDryRun, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin diff --git a/utils/src/modules/Engine/ReleaseSupport.psm1 b/utils/modules/Engine/ReleaseSupport.psm1 similarity index 86% rename from utils/src/modules/Engine/ReleaseSupport.psm1 rename to utils/modules/Engine/ReleaseSupport.psm1 index ffda8d7..fc59341 100644 --- a/utils/src/modules/Engine/ReleaseSupport.psm1 +++ b/utils/modules/Engine/ReleaseSupport.psm1 @@ -78,7 +78,12 @@ function New-EngineContext { [string]$SrcDir, [Parameter(Mandatory = $false)] - [psobject]$Settings + [psobject]$Settings, + + [switch]$DryRun, + + [ValidateSet('single', 'ha')] + [string]$DeployMode = 'ha' ) $resolvedVersion = Resolve-ReleaseVersion -Plugins $Plugins -ScriptDir $ScriptDir @@ -119,6 +124,23 @@ function New-EngineContext { $tag = "v$version" Write-Log -Level "INFO" -Message " Release tag default from ${versionSource}: $tag (ReleasePublishGuard may replace from git when publish is allowed)." + $dryRun = $false + if ($DryRun) { + $dryRun = $true + } + else { + $dryRun = Get-EngineDryRun -Settings $Settings + } + Write-Log -Level "INFO" -Message " Dry run (remote mutations only): $dryRun" + + $orchestrator = Get-MaksitOrchestrator + if ($orchestrator) { + Write-Log -Level "INFO" -Message " Orchestrator: $orchestrator (plugin profile filtering active)" + } + else { + Write-Log -Level "INFO" -Message " Orchestrator: not set (dev mode — all plugins eligible; engine probe still selects docker vs podman)" + } + return [pscustomobject]@{ scriptDir = $ScriptDir srcDir = $SrcDir @@ -132,6 +154,9 @@ function New-EngineContext { releaseBranches = $releaseBranches publishCompleted = $false skipPublishPlugins = $false + dryRun = $dryRun + deployMode = $DeployMode + orchestrator = $orchestrator } } diff --git a/utils/src/modules/Engine/TestSupport.psm1 b/utils/modules/Engine/TestSupport.psm1 similarity index 87% rename from utils/src/modules/Engine/TestSupport.psm1 rename to utils/modules/Engine/TestSupport.psm1 index a90d03d..03aeb46 100644 --- a/utils/src/modules/Engine/TestSupport.psm1 +++ b/utils/modules/Engine/TestSupport.psm1 @@ -19,7 +19,10 @@ function New-EngineContext { [string]$SrcDir, [Parameter(Mandatory = $false)] - [psobject]$Settings + [psobject]$Settings, + + [ValidateSet('single', 'ha')] + [string]$DeployMode = 'ha' ) $badgesDir = $null @@ -32,6 +35,7 @@ function New-EngineContext { srcDir = $SrcDir utilsDir = $SrcDir badgesDir = $badgesDir + deployMode = $DeployMode } } diff --git a/utils/src/modules/GitTools.psm1 b/utils/modules/GitTools.psm1 similarity index 100% rename from utils/src/modules/GitTools.psm1 rename to utils/modules/GitTools.psm1 diff --git a/utils/modules/HelmDeploySupport.psm1 b/utils/modules/HelmDeploySupport.psm1 new file mode 100644 index 0000000..e793e61 --- /dev/null +++ b/utils/modules/HelmDeploySupport.psm1 @@ -0,0 +1,631 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Shared helpers for HelmSelfDeploy (OCI chart upgrade/install against a live cluster). +#> + +if (-not (Get-Command Get-RegistryCredentialsFromRuntime -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path $PSScriptRoot -Parent + $pluginSupportModulePath = Join-Path $srcDir "Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +$scriptConfigModulePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'ScriptConfig.psm1' +if (Test-Path -LiteralPath $scriptConfigModulePath -PathType Leaf) { + Import-Module $scriptConfigModulePath -Force -Global -ErrorAction Stop +} + +function Get-ContainerRegistryCredentialsFromSecret { + param( + [Parameter(Mandatory = $true)] + [string]$SecretName, + + [Parameter(Mandatory = $false)] + [psobject]$SharedSettings + ) + + return Get-RegistryCredentialsFromRuntime -SecretName $SecretName -SharedSettings $SharedSettings +} + +function Test-HelmAvailable { + return [bool](Get-Command helm -ErrorAction SilentlyContinue) +} + +function Test-KubectlAvailable { + return [bool](Get-Command kubectl -ErrorAction SilentlyContinue) +} + +function Confirm-HelmAndKubectlAvailable { + if (-not (Test-HelmAvailable)) { + throw "Helm is not installed or not in PATH." + } + + if (-not (Test-KubectlAvailable)) { + throw "kubectl is not installed or not in PATH." + } +} + +function Test-KubectlClusterReachable { + kubectl cluster-info 2>$null | Out-Null + return $LASTEXITCODE -eq 0 +} + +function Get-HelmUpgradeDryRunArgs { + if (Test-KubectlClusterReachable) { + return @('--dry-run=server') + } + + Write-Host "Cluster not reachable via kubectl; using client-side helm --dry-run." + return @('--dry-run') +} + +function Test-SecretEnvironmentValueAvailable { + param( + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ([string]::IsNullOrWhiteSpace($Name)) { + return $false + } + + $value = [Environment]::GetEnvironmentVariable($Name) + return -not [string]::IsNullOrWhiteSpace($value) +} + +function Set-K8sNamespace { + param( + [Parameter(Mandatory = $true)] + [string]$Namespace + ) + + $Namespace = $Namespace.Trim() + if ([string]::IsNullOrWhiteSpace($Namespace)) { + throw "Namespace name is missing or empty." + } + + $ns = kubectl get namespace $Namespace --ignore-not-found -o name 2>$null + if ($LASTEXITCODE -ne 0) { + throw "kubectl get namespace failed for '$Namespace' (exit code $LASTEXITCODE)." + } + + if (-not $ns) { + Write-Host "Creating namespace $Namespace" + kubectl create namespace $Namespace | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Failed to create namespace $Namespace" + } + } +} + +function Wait-NamespacePodsReady { + param( + [Parameter(Mandatory = $true)] + [string]$Namespace, + [int]$TimeoutSeconds = 300, + [int]$PollIntervalSeconds = 5 + ) + + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ([DateTime]::UtcNow -lt $deadline) { + $json = kubectl get pods -n $Namespace -o json 2>$null + if ($LASTEXITCODE -ne 0) { + Start-Sleep -Seconds $PollIntervalSeconds + continue + } + + $obj = $json | ConvertFrom-Json + $pods = @($obj.items) + if ($pods.Count -eq 0) { + Start-Sleep -Seconds $PollIntervalSeconds + continue + } + + $ready = 0 + foreach ($p in $pods) { + $phase = if ($p.status) { $p.status.phase } else { "Unknown" } + $conditions = if ($p.status.conditions) { @($p.status.conditions) } else { @() } + $readyCond = $conditions | Where-Object { $_.type -eq "Ready" } | Select-Object -First 1 + if ($phase -eq "Running" -and $null -ne $readyCond -and $readyCond.status -eq "True") { + $ready++ + } + } + + if ($ready -eq $pods.Count) { + Write-Host "All $($pods.Count) pod(s) in namespace $Namespace are ready." + return $true + } + + Write-Host "Pods in $Namespace : $ready / $($pods.Count) ready" + Start-Sleep -Seconds $PollIntervalSeconds + } + + Write-Warning "Timeout after ${TimeoutSeconds}s: not all pods in namespace $Namespace are ready." + return $false +} + +function Get-DerivedOciChartRefFromChartEntry { + param( + [Parameter(Mandatory = $true)] + [object]$Chart, + [Parameter(Mandatory = $true)] + [string]$ChartVersion + ) + + if ($Chart.PSObject.Properties.Name -notcontains "containerRegistry" -or -not $Chart.containerRegistry) { + return $null + } + + $cr = $Chart.containerRegistry + $regHost = "" + if ($cr.PSObject.Properties.Name -contains "registryUrl" -and -not [string]::IsNullOrWhiteSpace([string]$cr.registryUrl)) { + $regHost = [string]$cr.registryUrl.Trim().TrimEnd('/') + $regHost = $regHost -replace '^(https?|oci)://', '' + } + + $ociChartsProject = if ($cr.PSObject.Properties.Name -contains "ociChartsProject" -and -not [string]::IsNullOrWhiteSpace([string]$cr.ociChartsProject)) { + [string]$cr.ociChartsProject.Trim().Trim('/') + } + else { + "" + } + + $chartProject = if ($cr.PSObject.Properties.Name -contains "chartProject" -and -not [string]::IsNullOrWhiteSpace([string]$cr.chartProject)) { + [string]$cr.chartProject.Trim().Trim('/') + } + else { + "" + } + + if ([string]::IsNullOrWhiteSpace($regHost) -or [string]::IsNullOrWhiteSpace($chartProject)) { + return $null + } + + $path = if ([string]::IsNullOrWhiteSpace($ociChartsProject)) { $chartProject } else { "$ociChartsProject/$chartProject" } + return "oci://${regHost}/${path}:${ChartVersion}" +} + +function Invoke-HelmOciChartRegistryLoginFromEnv { + param( + [Parameter(Mandatory = $true)] + [string]$RegistryHost, + [Parameter(Mandatory = $true)] + [string]$CredentialsSecret, + [psobject]$SharedSettings = $null + ) + + $RegistryHost = $RegistryHost.Trim().TrimEnd('/') + $helmCreds = Get-ContainerRegistryCredentialsFromSecret -SecretName $CredentialsSecret -SharedSettings $SharedSettings + Write-Host "Logging Helm into OCI chart registry $RegistryHost" + $prevEap = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $helmCreds.Password | & helm registry login $RegistryHost --username $helmCreds.User --password-stdin 2>&1 | Out-Null + } + finally { + $ErrorActionPreference = $prevEap + } + + if ($LASTEXITCODE -ne 0) { + throw "helm registry login failed for $RegistryHost (exit code $LASTEXITCODE)." + } +} + +function Set-KubernetesImagePullSecretFromEnv { + param( + [Parameter(Mandatory = $true)] + [string]$Namespace, + [Parameter(Mandatory = $true)] + [string]$RegistryHost, + [Parameter(Mandatory = $true)] + [string]$SecretName, + [Parameter(Mandatory = $true)] + [string]$CredentialsSecret, + [psobject]$SharedSettings = $null + ) + + $RegistryHost = $RegistryHost.Trim().TrimEnd('/') + $creds = Get-ContainerRegistryCredentialsFromSecret -SecretName $CredentialsSecret -SharedSettings $SharedSettings + Write-Host "Creating/updating image pull secret '$SecretName' in namespace $Namespace" + $createArgs = @( + "create", "secret", "docker-registry", $SecretName, + "--docker-server=$RegistryHost", + "--docker-username=$($creds.User)", + "--docker-password", $creds.Password, + "-n", $Namespace, + "--dry-run=client", "-o", "yaml" + ) + $secretYaml = & kubectl @createArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "kubectl create secret (dry-run) failed for '$SecretName': $secretYaml" + } + + $secretYaml | kubectl apply -f - + if ($LASTEXITCODE -ne 0) { + throw "kubectl apply failed for docker-registry secret '$SecretName' (exit code $LASTEXITCODE)." + } +} + +function Initialize-ContainerRegistryAuthForChart { + param( + [Parameter(Mandatory = $true)] + [string]$Namespace, + [Parameter(Mandatory = $true)] + [object]$Chart, + [string]$DefaultHelmCredentialsSecret = "", + [psobject]$SharedSettings = $null, + [switch]$DryRun + ) + + $reg = if ($Chart.PSObject.Properties.Name -contains "containerRegistry") { $Chart.containerRegistry } else { $null } + if ($null -eq $reg) { + return + } + + $rel = if ($Chart.releaseName) { [string]$Chart.releaseName } else { "" } + $baseUrl = if ($reg.PSObject.Properties.Name -contains "registryUrl" -and -not [string]::IsNullOrWhiteSpace([string]$reg.registryUrl)) { + [string]$reg.registryUrl.Trim().TrimEnd('/') + } + else { + "" + } + + $helmUrl = if ($reg.PSObject.Properties.Name -contains "helmRegistryUrl" -and -not [string]::IsNullOrWhiteSpace([string]$reg.helmRegistryUrl)) { + [string]$reg.helmRegistryUrl.Trim().TrimEnd('/') + } + else { + $baseUrl + } + + $imgUrl = if ($reg.PSObject.Properties.Name -contains "imagesRegistryUrl" -and -not [string]::IsNullOrWhiteSpace([string]$reg.imagesRegistryUrl)) { + [string]$reg.imagesRegistryUrl.Trim().TrimEnd('/') + } + else { + $baseUrl + } + + $helmCredsSecret = if ($reg.PSObject.Properties.Name -contains 'helmOciCredentialsSecret' -and -not [string]::IsNullOrWhiteSpace([string]$reg.helmOciCredentialsSecret)) { + [string]$reg.helmOciCredentialsSecret + } + elseif (-not [string]::IsNullOrWhiteSpace($DefaultHelmCredentialsSecret)) { + $DefaultHelmCredentialsSecret + } + else { + "" + } + + $imgCredsSecret = if ($reg.PSObject.Properties.Name -contains 'imagesCredentialsSecret' -and -not [string]::IsNullOrWhiteSpace([string]$reg.imagesCredentialsSecret)) { + [string]$reg.imagesCredentialsSecret + } + else { + "" + } + + $imgSecretName = if ($reg.PSObject.Properties.Name -contains "imagePullSecretName" -and -not [string]::IsNullOrWhiteSpace([string]$reg.imagePullSecretName)) { + [string]$reg.imagePullSecretName + } + else { + "cr-maks-it" + } + + if (-not [string]::IsNullOrWhiteSpace($helmCredsSecret)) { + if ([string]::IsNullOrWhiteSpace($helmUrl)) { + throw "Chart '$rel': containerRegistry.registryUrl is required for Helm OCI login." + } + + if ($DryRun -and -not (Test-SecretEnvironmentValueAvailable -Name $helmCredsSecret)) { + Write-Host "Dry run: skipping Helm OCI login; set env var '$helmCredsSecret' to validate remote OCI charts." + } + else { + Invoke-HelmOciChartRegistryLoginFromEnv -RegistryHost $helmUrl -CredentialsSecret $helmCredsSecret -SharedSettings $SharedSettings + } + } + + if (-not $DryRun -and -not [string]::IsNullOrWhiteSpace($imgCredsSecret)) { + if ([string]::IsNullOrWhiteSpace($imgUrl)) { + throw "Chart '$rel': containerRegistry.registryUrl is required for image pull secret." + } + + Set-KubernetesImagePullSecretFromEnv -Namespace $Namespace -RegistryHost $imgUrl -SecretName $imgSecretName -CredentialsSecret $imgCredsSecret -SharedSettings $SharedSettings + } +} + +function Test-HelmReleaseExists { + param( + [Parameter(Mandatory = $true)] + [string]$Namespace, + [Parameter(Mandatory = $true)] + [string]$ReleaseName + ) + + $releaseExists = helm list -n $Namespace -q 2>$null | Select-String -Pattern "^${ReleaseName}$" -Quiet + return [bool]$releaseExists +} + +function Invoke-PostgresClusterWait { + param( + [Parameter(Mandatory = $true)] + [object]$PostgresWait + ) + + if ($null -eq $PostgresWait -or $PostgresWait.enabled -ne $true) { + return + } + + $pgNs = if ($PostgresWait.namespace) { [string]$PostgresWait.namespace } else { "postgresql" } + $cluster = if ($PostgresWait.clusterName) { [string]$PostgresWait.clusterName } else { "postgresql" } + $timeout = if ($null -ne $PostgresWait.timeoutSeconds) { [int]$PostgresWait.timeoutSeconds } else { 600 } + Write-Host "postgresWait: waiting for CNPG cluster '$cluster' in namespace '$pgNs' (timeout ${timeout}s)..." + kubectl wait "cluster.postgresql.cnpg.io/$cluster" -n $pgNs --for=condition=Ready --timeout="${timeout}s" + if ($LASTEXITCODE -ne 0) { + throw "postgresWait failed: cluster '$cluster' in '$pgNs' did not become Ready." + } + + Write-Host "postgresWait: cluster is Ready." +} + +function Get-HelmSetArgsFromChartEntry { + param( + [Parameter(Mandatory = $true)] + [object]$Chart, + [Parameter(Mandatory = $true)] + [string]$ChartVersion + ) + + $setArg = @() + if ($Chart.PSObject.Properties.Name -notcontains 'helmSet' -or -not $Chart.helmSet) { + return $setArg + } + + foreach ($prop in $Chart.helmSet.PSObject.Properties) { + $val = ([string]$prop.Value) -replace '\{chartVersion\}', $ChartVersion + $setArg += '--set' + $setArg += "$($prop.Name)=$val" + } + + return $setArg +} + +function Invoke-HelmRemoteChartUpgrade { + param( + [Parameter(Mandatory = $true)] + [string]$Namespace, + [Parameter(Mandatory = $true)] + [object]$Chart, + [Parameter(Mandatory = $true)] + [string]$ChartVersion, + [Parameter(Mandatory = $true)] + [string]$DeploySettingsDir, + [string]$DefaultHelmCredentialsSecret = "", + [psobject]$SharedSettings = $null, + [switch]$DryRun + ) + + $releaseName = [string]$Chart.releaseName + if ([string]::IsNullOrWhiteSpace($releaseName)) { + throw "Chart entry is missing releaseName." + } + + Initialize-ContainerRegistryAuthForChart -Namespace $Namespace -Chart $Chart -DefaultHelmCredentialsSecret $DefaultHelmCredentialsSecret -SharedSettings $SharedSettings -DryRun:$DryRun + + $ociRef = Get-DerivedOciChartRefFromChartEntry -Chart $Chart -ChartVersion $ChartVersion + if ([string]::IsNullOrWhiteSpace($ociRef)) { + throw "Could not derive OCI chart reference for release '$releaseName'. Set chart.containerRegistry.registryUrl and chartProject." + } + + $valuesArg = @() + $valuesPath = $null + if ($Chart.PSObject.Properties.Name -contains 'valuesFile' -and -not [string]::IsNullOrWhiteSpace([string]$Chart.valuesFile)) { + $deployMode = $null + if ($null -ne $SharedSettings -and $SharedSettings.PSObject.Properties.Name -contains 'deployMode') { + $deployMode = [string]$SharedSettings.deployMode + } + + if ($deployMode -in @('single', 'ha')) { + $valuesPath = Resolve-DeployValuesFilePath -DeploySettingsDir $DeploySettingsDir -ValuesFile ([string]$Chart.valuesFile) -DeployMode $deployMode + } + else { + throw "HelmSelfDeploy requires deployMode 'single' or 'ha' when valuesFile is set." + } + + $valuesArg = @('-f', $valuesPath) + } + + $releaseExists = Test-HelmReleaseExists -Namespace $Namespace -ReleaseName $releaseName + $reuseArg = @() + if ($valuesArg.Count -gt 0) { + if ($releaseExists) { + Write-Host "Upgrading existing release '$releaseName' in namespace '$Namespace' with values file $valuesPath (chart version $ChartVersion)" + } + else { + Write-Host "Installing new release '$releaseName' in namespace '$Namespace' with values file $valuesPath (chart version $ChartVersion)" + } + } + elseif ($releaseExists) { + Write-Host "Upgrading existing release '$releaseName' in namespace '$Namespace' to chart version $ChartVersion (reuse-values)" + $reuseArg = @("--reuse-values") + } + else { + throw "Release '$releaseName' is not installed in namespace '$Namespace'. Add values.single.yaml and values.ha.yaml beside scriptSettings.json and run utils/Invoke-ReleasePackage-HA.bat or Invoke-ReleasePackage-Single.bat for first install." + } + + $setArg = Get-HelmSetArgsFromChartEntry -Chart $Chart -ChartVersion $ChartVersion + $dryRunArg = if ($DryRun) { Get-HelmUpgradeDryRunArgs } else { @() } + + if ($DryRun) { + if ($valuesArg.Count -gt 0) { + Write-Host "Dry run: helm upgrade --install '$releaseName' in namespace '$Namespace' with values file $valuesPath (chart version $ChartVersion)" + } + else { + Write-Host "Dry run: helm upgrade --install '$releaseName' in namespace '$Namespace' to chart version $ChartVersion" + } + } + + helm upgrade --install $releaseName $ociRef -n $Namespace --create-namespace --version $ChartVersion @reuseArg @valuesArg @setArg @dryRunArg + if ($LASTEXITCODE -ne 0) { + $action = if ($DryRun) { 'Helm dry-run upgrade/install' } else { 'Helm upgrade/install' } + throw "$action failed for release '$releaseName' (exit code $LASTEXITCODE)." + } +} + +function Remove-BgpServices { + param( + [Parameter(Mandatory = $true)] + [string]$Namespace, + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + [Parameter(Mandatory = $true)] + [string]$ReleaseName, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$BgpServices, + [Parameter(Mandatory = $true)] + [int]$DefaultPort + ) + + foreach ($svc in $BgpServices) { + $template = $svc.template + $ip = $svc.ip + $path = Join-Path $ScriptDir $template + if (-not (Test-Path $path)) { + Write-Host "BGP template not found (skip delete): $path" + continue + } + + Write-Host "Removing BGP service $template (IP: $ip)" + $content = Get-Content $path -Raw + $portStr = if ($null -ne $svc.port) { [string]$svc.port } else { [string]$DefaultPort } + $content = $content -replace "__RELEASE_NAME__", $ReleaseName -replace "__NAMESPACE__", $Namespace -replace "__BGP_IP__", $ip -replace "__PORT__", $portStr + $content | kubectl delete -f - --ignore-not-found 2>$null + } +} + +function Invoke-HelmReleaseUninstall { + param( + [Parameter(Mandatory = $true)] + [string]$Namespace, + [Parameter(Mandatory = $true)] + [object]$Chart + ) + + $releaseName = [string]$Chart.releaseName + if ([string]::IsNullOrWhiteSpace($releaseName)) { + throw "Chart entry is missing releaseName." + } + + if (-not (Test-HelmReleaseExists -Namespace $Namespace -ReleaseName $releaseName)) { + Write-Host "Release '$releaseName' not found in namespace '$Namespace', nothing to uninstall." + return + } + + Write-Host "Uninstalling Helm release '$releaseName' from namespace '$Namespace' ..." + $prevEap = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + helm uninstall $releaseName -n $Namespace 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "helm uninstall failed for release '$releaseName' (exit code $LASTEXITCODE)." + } + } + finally { + $ErrorActionPreference = $prevEap + } +} + +function Wait-TcpConnectivity { + param( + [Parameter(Mandatory = $true)] + [string]$ComputerName, + [Parameter(Mandatory = $true)] + [int]$Port, + [int]$TimeoutSeconds = 300, + [int]$PollIntervalSeconds = 3 + ) + + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + while ([DateTime]::UtcNow -lt $deadline) { + $result = Test-NetConnection -ComputerName $ComputerName -Port $Port -WarningAction SilentlyContinue + if ($result.TcpTestSucceeded) { + Write-Host "Connectivity OK (${ComputerName}:${Port})" + return $true + } + + Start-Sleep -Seconds $PollIntervalSeconds + } + + Write-Warning "Timeout after ${TimeoutSeconds}s: no connectivity to ${ComputerName}:${Port}" + return $false +} + +function Set-BgpServices { + param( + [Parameter(Mandatory = $true)] + [string]$Namespace, + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + [Parameter(Mandatory = $true)] + [string]$ReleaseName, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$BgpServices, + [Parameter(Mandatory = $true)] + [int]$DefaultPort + ) + + foreach ($svc in $BgpServices) { + $template = $svc.template + $ip = $svc.ip + $path = Join-Path $ScriptDir $template + if (-not (Test-Path $path)) { + Write-Warning "BGP template not found: $path" + continue + } + + Write-Host "Applying BGP service $template (IP: $ip)" + $content = Get-Content $path -Raw + $portStr = if ($null -ne $svc.port) { [string]$svc.port } else { [string]$DefaultPort } + $content = $content -replace "__RELEASE_NAME__", $ReleaseName -replace "__NAMESPACE__", $Namespace -replace "__BGP_IP__", $ip -replace "__PORT__", $portStr + $content | kubectl apply -f - + if ($LASTEXITCODE -ne 0) { + throw "kubectl apply failed for BGP template $template" + } + } +} + +function Test-BgpConnectivity { + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [object[]]$BgpServices, + [int]$TimeoutSeconds = 300, + [int]$PollIntervalSeconds = 3 + ) + + $toTest = @($BgpServices | Where-Object { $null -ne $_.port }) + if ($toTest.Count -eq 0) { + Write-Warning "No BGP services have port set; skipping connectivity test." + return + } + + foreach ($svc in $toTest) { + $h = $svc.ip + $p = [int]$svc.port + Write-Host "Waiting for connectivity to ${h}:${p}..." + Wait-TcpConnectivity -ComputerName $h -Port $p -TimeoutSeconds $TimeoutSeconds -PollIntervalSeconds $PollIntervalSeconds | Out-Null + } +} + +Export-ModuleMember -Function ` + Confirm-HelmAndKubectlAvailable, ` + Set-K8sNamespace, ` + Wait-NamespacePodsReady, ` + Invoke-PostgresClusterWait, ` + Invoke-HelmRemoteChartUpgrade, ` + Invoke-HelmReleaseUninstall, ` + Set-BgpServices, ` + Remove-BgpServices, ` + Test-BgpConnectivity diff --git a/utils/src/modules/Logging.psm1 b/utils/modules/Logging.psm1 similarity index 100% rename from utils/src/modules/Logging.psm1 rename to utils/modules/Logging.psm1 diff --git a/utils/modules/ScriptConfig.psm1 b/utils/modules/ScriptConfig.psm1 new file mode 100644 index 0000000..b8895b1 --- /dev/null +++ b/utils/modules/ScriptConfig.psm1 @@ -0,0 +1,135 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +function Get-ScriptSettings { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ScriptDir, + + [ValidateSet('single', 'ha')] + [string]$Mode, + + [Parameter(Mandatory = $false)] + [string]$SettingsFileName = 'scriptSettings.json' + ) + + $settingsPath = if ($PSBoundParameters.ContainsKey('Mode')) { + $modePath = Join-Path $ScriptDir "scriptSettings.$Mode.json" + if (Test-Path -LiteralPath $modePath -PathType Leaf) { + $modePath + } + else { + Join-Path $ScriptDir $SettingsFileName + } + } + else { + Join-Path $ScriptDir $SettingsFileName + } + + if (-not (Test-Path -LiteralPath $settingsPath -PathType Leaf)) { + throw "Settings file not found: $settingsPath" + } + + return Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json +} + +function Get-HelmSelfDeployPluginFromSettings { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [psobject]$Settings + ) + + if (-not ($Settings.PSObject.Properties.Name -contains 'plugins') -or -not $Settings.plugins) { + return $null + } + + foreach ($plugin in @($Settings.plugins)) { + if ($plugin.name -eq 'HelmSelfDeploy') { + return $plugin + } + } + + return $null +} + +function Get-DeployModeFromSettings { + <# + .SYNOPSIS + Resolves cluster deploy profile from HelmSelfDeploy plugin settings or an explicit CLI override. + + .DESCRIPTION + CI/CD pipelines omit -Mode and read deployMode on the HelmSelfDeploy plugin. + Manual installs use Invoke-ReleasePackage-Single.bat / Invoke-ReleasePackage-HA.bat. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [psobject]$Settings, + + [ValidateSet('single', 'ha')] + [string]$ModeOverride + ) + + if ($PSBoundParameters.ContainsKey('ModeOverride')) { + return $ModeOverride + } + + $helmSelfDeploy = Get-HelmSelfDeployPluginFromSettings -Settings $Settings + if ($null -ne $helmSelfDeploy -and $helmSelfDeploy.PSObject.Properties.Name -contains 'deployMode') { + $mode = [string]$helmSelfDeploy.deployMode + if (-not [string]::IsNullOrWhiteSpace($mode)) { + $mode = $mode.Trim().ToLowerInvariant() + if ($mode -in @('single', 'ha')) { + return $mode + } + + throw "HelmSelfDeploy.deployMode must be 'single' or 'ha' (got '$mode')." + } + } + + return 'ha' +} + +function Resolve-DeployValuesFilePath { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$DeploySettingsDir, + + [Parameter(Mandatory = $true)] + [string]$ValuesFile, + + [Parameter(Mandatory = $true)] + [ValidateSet('single', 'ha')] + [string]$DeployMode + ) + + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($ValuesFile) + $extension = [System.IO.Path]::GetExtension($ValuesFile) + if ([string]::IsNullOrEmpty($extension)) { + $extension = '.yaml' + } + + $valuesPath = Join-Path $DeploySettingsDir "$baseName.$DeployMode$extension" + if (Test-Path -LiteralPath $valuesPath -PathType Leaf) { + return $valuesPath + } + + throw "Deploy values file not found: '$valuesPath'. HelmSelfDeploy expects values.single.yaml or values.ha.yaml beside scriptSettings.json." +} + +function Assert-Command { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Command + ) + + if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { + throw "Required command '$Command' is missing. Aborting." + } +} + +Export-ModuleMember -Function Get-ScriptSettings, Get-DeployModeFromSettings, Resolve-DeployValuesFilePath, Assert-Command diff --git a/utils/src/modules/TestRunner.psm1 b/utils/modules/TestRunner.psm1 similarity index 100% rename from utils/src/modules/TestRunner.psm1 rename to utils/modules/TestRunner.psm1 diff --git a/utils/plugins/Docker/DockerCompose.psm1 b/utils/plugins/Docker/DockerCompose.psm1 new file mode 100644 index 0000000..df2d3a8 --- /dev/null +++ b/utils/plugins/Docker/DockerCompose.psm1 @@ -0,0 +1,35 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Docker Compose plugin — validate or bring up the stack via docker compose. + +.DESCRIPTION + Requires ContainerEngineProbe first. Skips safely when activeContainerEngine is podman. + Orchestrator filtering (Compose-only) applies only when MAKSIT_ORCHESTRATOR is set (CI). +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'ComposeDeploySupport' -RequiredCommand 'Invoke-ComposeStackPlugin' + Invoke-ComposeStackPlugin -Settings $Settings -RequiredEngine 'docker' -PluginName 'DockerCompose' +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true; orchestrators = @('Compose') } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Docker/DockerImagePush.psm1 b/utils/plugins/Docker/DockerImagePush.psm1 new file mode 100644 index 0000000..9f46c1a --- /dev/null +++ b/utils/plugins/Docker/DockerImagePush.psm1 @@ -0,0 +1,43 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Docker image publish plugin — build and push images via docker (Docker Desktop / local docker CLI). + +.DESCRIPTION + Requires ContainerEngineProbe first. Skips safely when activeContainerEngine is podman. + Builds any Dockerfile listed in images[]; not limited to .NET projects. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'ContainerEngineSupport' -RequiredCommand 'Test-ShouldRunContainerEnginePlugin' + Import-PluginDependency -ModuleName 'ContainerImagePushSupport' -RequiredCommand 'Invoke-ContainerImagePush' + + $shared = $Settings.context + if (-not (Test-ShouldRunContainerEnginePlugin -RequiredEngine 'docker' -SharedSettings $shared -PluginName 'DockerImagePush')) { + return + } + + Invoke-ContainerImagePush -Settings $Settings -PluginDisplayName 'DockerImagePush' +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/src/plugins/DotNet/DotNetCleanupArtifacts.psm1 b/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 similarity index 100% rename from utils/src/plugins/DotNet/DotNetCleanupArtifacts.psm1 rename to utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 diff --git a/utils/src/plugins/DotNet/DotNetCreateArchive.psm1 b/utils/plugins/DotNet/DotNetCreateArchive.psm1 similarity index 100% rename from utils/src/plugins/DotNet/DotNetCreateArchive.psm1 rename to utils/plugins/DotNet/DotNetCreateArchive.psm1 diff --git a/utils/src/plugins/DotNet/DotNetNuGet.psm1 b/utils/plugins/DotNet/DotNetNuGet.psm1 similarity index 60% rename from utils/src/plugins/DotNet/DotNetNuGet.psm1 rename to utils/plugins/DotNet/DotNetNuGet.psm1 index ecb2eb0..535fae4 100644 --- a/utils/src/plugins/DotNet/DotNetNuGet.psm1 +++ b/utils/plugins/DotNet/DotNetNuGet.psm1 @@ -29,22 +29,36 @@ function Invoke-Plugin { $pluginSettings = $Settings $sharedSettings = $Settings.context - $nugetApiKeyEnvVar = $pluginSettings.nugetApiKey + $nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' $packageFile = $sharedSettings.packageFile + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings + Assert-Command dotnet if (-not $packageFile) { throw "DotNetNuGet plugin requires a NuGet package artifact. Ensure DotNetPack produced a .nupkg before running DotNetNuGet." } - if ([string]::IsNullOrWhiteSpace($nugetApiKeyEnvVar)) { - throw "DotNetNuGet plugin requires 'nugetApiKey' in scriptSettings.json." + if ($dryRun) { + $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { + "https://api.nuget.org/v3/index.json" + } + else { + $pluginSettings.source + } + + Write-Log -Level "INFO" -Message "Dry run: would push $($packageFile.FullName) to $nugetSource" + return } - $nugetApiKey = [System.Environment]::GetEnvironmentVariable($nugetApiKeyEnvVar) - if ([string]::IsNullOrWhiteSpace($nugetApiKey)) { - throw "NuGet API key is not set. Set '$nugetApiKeyEnvVar' and rerun." + if ([string]::IsNullOrWhiteSpace($nugetSecret)) { + throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)." + } + + $nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret + if ([string]::IsNullOrWhiteSpace($nugetKey)) { + throw "NuGet API key is not set. Set environment variable '$nugetSecret'." } $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { @@ -55,7 +69,7 @@ function Invoke-Plugin { } Write-Log -Level "STEP" -Message "Pushing package to NuGet feed..." - dotnet nuget push $packageFile.FullName -k $nugetApiKey -s $nugetSource --skip-duplicate + dotnet nuget push $packageFile.FullName -k $nugetKey -s $nugetSource --skip-duplicate if ($LASTEXITCODE -ne 0) { throw "Failed to push the package to NuGet feed." @@ -65,7 +79,11 @@ function Invoke-Plugin { $sharedSettings | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/src/plugins/DotNet/DotNetPack.psm1 b/utils/plugins/DotNet/DotNetPack.psm1 similarity index 100% rename from utils/src/plugins/DotNet/DotNetPack.psm1 rename to utils/plugins/DotNet/DotNetPack.psm1 diff --git a/utils/src/plugins/DotNet/DotNetPublish.psm1 b/utils/plugins/DotNet/DotNetPublish.psm1 similarity index 100% rename from utils/src/plugins/DotNet/DotNetPublish.psm1 rename to utils/plugins/DotNet/DotNetPublish.psm1 diff --git a/utils/src/plugins/DotNet/DotNetReleaseVersion.psm1 b/utils/plugins/DotNet/DotNetReleaseVersion.psm1 similarity index 100% rename from utils/src/plugins/DotNet/DotNetReleaseVersion.psm1 rename to utils/plugins/DotNet/DotNetReleaseVersion.psm1 diff --git a/utils/src/plugins/DotNet/DotNetTest.psm1 b/utils/plugins/DotNet/DotNetTest.psm1 similarity index 100% rename from utils/src/plugins/DotNet/DotNetTest.psm1 rename to utils/plugins/DotNet/DotNetTest.psm1 diff --git a/utils/src/plugins/DotNet/DotNetHelmPush.psm1 b/utils/plugins/Helm/HelmPush.psm1 similarity index 74% rename from utils/src/plugins/DotNet/DotNetHelmPush.psm1 rename to utils/plugins/Helm/HelmPush.psm1 index 44532f0..eb16bd5 100644 --- a/utils/src/plugins/DotNet/DotNetHelmPush.psm1 +++ b/utils/plugins/Helm/HelmPush.psm1 @@ -3,13 +3,13 @@ <# .SYNOPSIS - .NET Helm publish plugin — package and push charts versioned from DotNetReleaseVersion. + Helm publish plugin — package and push charts versioned from shared release context. .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. + overwrites them with the bare semver from shared context (shared.version from a release-version + plugin, e.g. DotNetReleaseVersion or NpmReleaseVersion — 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. @@ -17,38 +17,12 @@ if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" if (Test-Path $pluginSupportModulePath -PathType Leaf) { Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop } } -function Get-RegistryCredentialsFromEnv { - param( - [Parameter(Mandatory = $true)] - [string]$EnvVarName - ) - - $raw = [Environment]::GetEnvironmentVariable($EnvVarName) - if ([string]::IsNullOrWhiteSpace($raw)) { - throw "Environment variable '$EnvVarName' is not set." - } - - try { - $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) - } - catch { - throw "Failed to decode '$EnvVarName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" - } - - $parts = $decoded -split ':', 2 - if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { - throw "Decoded '$EnvVarName' must be in the form 'username:password'." - } - - return @{ User = $parts[0]; Password = $parts[1] } -} - function Invoke-Plugin { param( [Parameter(Mandatory = $true)] @@ -61,20 +35,25 @@ function Invoke-Plugin { $pluginSettings = $Settings $shared = $Settings.context + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared + Assert-Command helm $pushLatest = if ($null -ne $pluginSettings.pushLatest) { [bool]$pluginSettings.pushLatest } else { $false } if ([string]::IsNullOrWhiteSpace($pluginSettings.chartPath)) { - throw "DotNetHelmPush plugin requires 'chartPath' (chart directory, relative to engines/release folder)." + throw "HelmPush plugin requires 'chartPath' (chart directory, relative to engines/release folder)." } if ([string]::IsNullOrWhiteSpace($pluginSettings.ociRepository)) { - throw "DotNetHelmPush plugin requires 'ociRepository' (e.g. oci://cr.maks-it.com/charts)." + throw "HelmPush plugin requires 'ociRepository' (e.g. oci://cr.maks-it.com/charts)." } - if ([string]::IsNullOrWhiteSpace($pluginSettings.credentialsEnvVar)) { - throw "DotNetHelmPush plugin requires 'credentialsEnvVar' (name of env var holding base64 username:password)." + if (-not $dryRun) { + $containerRegistrySecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'containerRegistrySecret' + if ([string]::IsNullOrWhiteSpace($containerRegistrySecret)) { + throw "HelmPush plugin requires 'containerRegistrySecret' (logical secret name, e.g. ContainerRegistry)." + } } $scriptDir = $shared.ScriptDir @@ -93,10 +72,9 @@ function Invoke-Plugin { $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)." + throw "Could not derive chart version: need shared.version (release version plugin) 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 @@ -133,6 +111,16 @@ function Invoke-Plugin { throw "Expected chart package not found: $chartPackage (helm output: $packageOutput)" } + if ($dryRun) { + Write-Log -Level "INFO" -Message "Dry run: would push $chartPackage to $ociRepository" + Remove-Item -LiteralPath $chartPackage -Force -ErrorAction SilentlyContinue + Write-Log -Level "OK" -Message " Helm chart dry run completed." + return + } + + $creds = Get-RegistryCredentialsFromRuntime -SecretName $containerRegistrySecret -SharedSettings $shared + $ociRepository = [string]$pluginSettings.ociRepository.TrimEnd('/') + Write-Log -Level "STEP" -Message "Pushing $chartPackage to $ociRepository ..." helm push $chartPackage $ociRepository --username $creds.User --password $creds.Password if ($LASTEXITCODE -ne 0) { @@ -178,4 +166,8 @@ function Invoke-Plugin { $shared | Add-Member -NotePropertyName publishCompleted -NotePropertyValue $true -Force } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Helm/HelmSelfDeploy.psm1 b/utils/plugins/Helm/HelmSelfDeploy.psm1 new file mode 100644 index 0000000..cd2b856 --- /dev/null +++ b/utils/plugins/Helm/HelmSelfDeploy.psm1 @@ -0,0 +1,174 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Helm self-deploy plugin — upgrade/install the release chart on the target cluster after HelmPush. + +.DESCRIPTION + Deployment settings (namespace, postgresWait, charts) are configured on the HelmSelfDeploy plugin + entry in scriptSettings.json. Chart version comes from shared.version (DotNetReleaseVersion). + Existing releases with valuesFile use -f on every upgrade. Without valuesFile, upgrades use --reuse-values. helmSet supports {chartVersion} for --set overrides (e.g. image tags). +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "HelmDeploySupport" -RequiredCommand "Invoke-HelmRemoteChartUpgrade" + + $pluginSettings = $Settings + $shared = $Settings.context + $scriptDir = [string]$shared.ScriptDir + + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared + + if (-not $pluginSettings.namespace) { + throw "HelmSelfDeploy plugin requires 'namespace'." + } + + if (-not $pluginSettings.charts -or @($pluginSettings.charts).Count -eq 0) { + throw "HelmSelfDeploy plugin requires at least one chart in 'charts'." + } + + $containerRegistrySecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'containerRegistrySecret' + if (-not $dryRun -and [string]::IsNullOrWhiteSpace($containerRegistrySecret)) { + throw "HelmSelfDeploy plugin requires 'containerRegistrySecret' (logical secret name, e.g. ContainerRegistry)." + } + + $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 "HelmSelfDeploy could not derive chart version from shared.version or shared.tag." + } + + Confirm-HelmAndKubectlAvailable + + if ($dryRun) { + Write-Log -Level "INFO" -Message "HelmSelfDeploy dry run: validating helm upgrade/install for namespace '$($pluginSettings.namespace)'." + } + + $namespace = [string]$pluginSettings.namespace + if (-not $dryRun) { + Set-K8sNamespace -Namespace $namespace + } + + $deployMode = 'ha' + if ($shared.PSObject.Properties.Name -contains 'deployMode' -and -not [string]::IsNullOrWhiteSpace([string]$shared.deployMode)) { + $deployMode = [string]$shared.deployMode + } + + Write-Log -Level 'INFO' -Message "HelmSelfDeploy deploy mode: $deployMode (values.{mode}.yaml when present)." + + $skipPostgresWait = $false + if ($pluginSettings.PSObject.Properties.Name -contains 'skipPostgresWait') { + $skipPostgresWait = [bool]$pluginSettings.skipPostgresWait + } + + if (-not $dryRun -and -not $skipPostgresWait -and $pluginSettings.PSObject.Properties.Name -contains 'postgresWait') { + Invoke-PostgresClusterWait -PostgresWait $pluginSettings.postgresWait + } + + foreach ($chart in @($pluginSettings.charts)) { + if ($chart.source -and [string]$chart.source -ne "remote") { + Write-Log -Level "INFO" -Message "Skipping non-remote chart entry (source=$($chart.source))." + continue + } + + $stepMessage = if ($dryRun) { + "Dry run: validating Helm release '$($chart.releaseName)' version $chartVersion ..." + } + else { + "Self-deploying Helm release '$($chart.releaseName)' version $chartVersion ..." + } + + Write-Log -Level "STEP" -Message $stepMessage + Invoke-HelmRemoteChartUpgrade ` + -Namespace $namespace ` + -Chart $chart ` + -ChartVersion $chartVersion ` + -DeploySettingsDir $scriptDir ` + -DefaultHelmCredentialsSecret $containerRegistrySecret ` + -SharedSettings $shared ` + -DryRun:$dryRun + } + + if ($dryRun) { + Write-Log -Level "INFO" -Message "HelmSelfDeploy dry run completed for namespace '$namespace'." + return + } + + $waitForPods = $true + if ($pluginSettings.PSObject.Properties.Name -contains 'waitForPodsReady') { + $waitForPods = [bool]$pluginSettings.waitForPodsReady + } + + if ($waitForPods) { + $timeout = 600 + if ($pluginSettings.PSObject.Properties.Name -contains 'podsReadyTimeoutSeconds' -and $null -ne $pluginSettings.podsReadyTimeoutSeconds) { + $timeout = [int]$pluginSettings.podsReadyTimeoutSeconds + } + + if (-not (Wait-NamespacePodsReady -Namespace $namespace -TimeoutSeconds $timeout)) { + throw "Not all pods became ready in namespace '$namespace' within ${timeout}s." + } + } + + $bgpServices = @() + if ($pluginSettings.PSObject.Properties.Name -contains 'bgpServices' -and $pluginSettings.bgpServices) { + $bgpServices = @($pluginSettings.bgpServices) + } + + if ($bgpServices.Count -gt 0) { + Import-PluginDependency -ModuleName "HelmDeploySupport" -RequiredCommand "Set-BgpServices" + $releaseName = [string]$pluginSettings.charts[0].releaseName + $bgpDefaultPort = 80 + if ($pluginSettings.PSObject.Properties.Name -contains 'bgpDefaultPort' -and $null -ne $pluginSettings.bgpDefaultPort) { + $bgpDefaultPort = [int]$pluginSettings.bgpDefaultPort + } + + Write-Log -Level "STEP" -Message "Applying $($bgpServices.Count) BGP service(s) for release '$releaseName' ..." + Set-BgpServices -Namespace $namespace -ScriptDir $scriptDir -ReleaseName $releaseName -BgpServices $bgpServices -DefaultPort $bgpDefaultPort + + $testBgp = $false + if ($pluginSettings.PSObject.Properties.Name -contains 'testBgpConnectivity') { + $testBgp = [bool]$pluginSettings.testBgpConnectivity + } + + if ($testBgp) { + Import-PluginDependency -ModuleName "HelmDeploySupport" -RequiredCommand "Test-BgpConnectivity" + $bgpTimeout = 300 + if ($pluginSettings.PSObject.Properties.Name -contains 'bgpConnectivityTimeoutSeconds' -and $null -ne $pluginSettings.bgpConnectivityTimeoutSeconds) { + $bgpTimeout = [int]$pluginSettings.bgpConnectivityTimeoutSeconds + } + + Test-BgpConnectivity -BgpServices $bgpServices -TimeoutSeconds $bgpTimeout + } + } + + Write-Log -Level "INFO" -Message "HelmSelfDeploy completed for namespace '$namespace'." +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Helm/HelmUninstall.psm1 b/utils/plugins/Helm/HelmUninstall.psm1 new file mode 100644 index 0000000..d45884c --- /dev/null +++ b/utils/plugins/Helm/HelmUninstall.psm1 @@ -0,0 +1,166 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Helm uninstall plugin — remove Helm release(s) and BGP LoadBalancer services from the target cluster. + +.DESCRIPTION + By default inherits namespace, charts, and bgpServices from the enabled HelmSelfDeploy entry in + engines/release/scriptSettings.json (releaseSettingsFile, default ..\\release\\scriptSettings.json). + Optional overrides on this plugin: deleteNamespace, releaseSettingsFile. + kubectl/helm use KUBECONFIG, default kubeconfig, or in-cluster service account credentials. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Resolve-HelmUninstallDeploySettings { + param( + [Parameter(Mandatory = $true)] + $PluginSettings, + + [Parameter(Mandatory = $true)] + [string]$EngineDir + ) + + $inherit = $true + if ($PluginSettings.PSObject.Properties.Name -contains 'inheritFromRelease') { + $inherit = [bool]$PluginSettings.inheritFromRelease + } + elseif ($PluginSettings.PSObject.Properties.Name -contains 'namespace' -and -not [string]::IsNullOrWhiteSpace([string]$PluginSettings.namespace)) { + $inherit = $false + } + + if (-not $inherit) { + return $PluginSettings + } + + $releaseSettingsFile = '..\release\scriptSettings.json' + if ($PluginSettings.PSObject.Properties.Name -contains 'releaseSettingsFile' -and -not [string]::IsNullOrWhiteSpace([string]$PluginSettings.releaseSettingsFile)) { + $releaseSettingsFile = [string]$PluginSettings.releaseSettingsFile + } + + $releaseSettingsPath = [System.IO.Path]::GetFullPath((Join-Path $EngineDir $releaseSettingsFile)) + if (-not (Test-Path $releaseSettingsPath -PathType Leaf)) { + throw "HelmUninstall could not find release settings at '$releaseSettingsPath' (releaseSettingsFile)." + } + + $releaseSettings = Get-Content $releaseSettingsPath -Raw | ConvertFrom-Json + $deployPlugin = $null + if ($releaseSettings.PSObject.Properties.Name -contains 'plugins' -and $releaseSettings.plugins) { + foreach ($candidate in @($releaseSettings.plugins)) { + if ($candidate.name -eq 'HelmSelfDeploy' -and $candidate.enabled) { + $deployPlugin = $candidate + break + } + } + } + + if ($null -eq $deployPlugin) { + throw "HelmUninstall inheritFromRelease: no enabled HelmSelfDeploy plugin in '$releaseSettingsPath'." + } + + $merged = @{} + foreach ($property in $deployPlugin.PSObject.Properties) { + $merged[$property.Name] = $property.Value + } + + foreach ($property in $PluginSettings.PSObject.Properties) { + if ($property.Name -in @('name', 'stageLabel', 'enabled', 'inheritFromRelease', 'releaseSettingsFile')) { + continue + } + + $merged[$property.Name] = $property.Value + } + + if (-not $merged.ContainsKey('settingsDir') -or [string]::IsNullOrWhiteSpace([string]$merged['settingsDir'])) { + $merged['settingsDir'] = '..\release' + } + + return [pscustomobject]$merged +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "HelmDeploySupport" -RequiredCommand "Invoke-HelmReleaseUninstall" + + $shared = $Settings.context + $engineDir = [string]$shared.ScriptDir + $pluginSettings = Resolve-HelmUninstallDeploySettings -PluginSettings $Settings -EngineDir $engineDir + + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared + if ($dryRun) { + Write-Log -Level "INFO" -Message "Dry run: would uninstall Helm release(s) in namespace '$($pluginSettings.namespace)'." + return + } + + if (-not $pluginSettings.namespace) { + throw "HelmUninstall requires 'namespace' (set explicitly or inherit from HelmSelfDeploy)." + } + + if (-not $pluginSettings.charts -or @($pluginSettings.charts).Count -eq 0) { + throw "HelmUninstall requires at least one chart in 'charts' (inherit from HelmSelfDeploy or set explicitly)." + } + + $settingsDir = $engineDir + if ($pluginSettings.PSObject.Properties.Name -contains 'settingsDir' -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.settingsDir)) { + $settingsDir = [System.IO.Path]::GetFullPath((Join-Path $engineDir ([string]$pluginSettings.settingsDir))) + } + + Confirm-HelmAndKubectlAvailable + $namespace = [string]$pluginSettings.namespace + + foreach ($chart in @($pluginSettings.charts)) { + Write-Log -Level "STEP" -Message "Removing Helm release '$($chart.releaseName)' ..." + Invoke-HelmReleaseUninstall -Namespace $namespace -Chart $chart + } + + $bgpServices = @() + if ($pluginSettings.PSObject.Properties.Name -contains 'bgpServices' -and $pluginSettings.bgpServices) { + $bgpServices = @($pluginSettings.bgpServices) + } + + if ($bgpServices.Count -gt 0) { + Import-PluginDependency -ModuleName "HelmDeploySupport" -RequiredCommand "Remove-BgpServices" + $releaseName = [string]$pluginSettings.charts[0].releaseName + $bgpDefaultPort = 80 + if ($pluginSettings.PSObject.Properties.Name -contains 'bgpDefaultPort' -and $null -ne $pluginSettings.bgpDefaultPort) { + $bgpDefaultPort = [int]$pluginSettings.bgpDefaultPort + } + + Write-Log -Level "STEP" -Message "Removing $($bgpServices.Count) BGP service(s) for release '$releaseName' ..." + Remove-BgpServices -Namespace $namespace -ScriptDir $settingsDir -ReleaseName $releaseName -BgpServices $bgpServices -DefaultPort $bgpDefaultPort + } + + $deleteNamespace = $false + if ($Settings.PSObject.Properties.Name -contains 'deleteNamespace') { + $deleteNamespace = [bool]$Settings.deleteNamespace + } + + if ($deleteNamespace) { + Write-Log -Level "STEP" -Message "Deleting namespace '$namespace' ..." + kubectl delete namespace $namespace --ignore-not-found 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { + throw "kubectl delete namespace failed for '$namespace' (exit code $LASTEXITCODE)." + } + } + + Write-Log -Level "INFO" -Message "HelmUninstall completed for namespace '$namespace'." +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/src/plugins/Npm/NpmBuild.psm1 b/utils/plugins/Npm/NpmBuild.psm1 similarity index 100% rename from utils/src/plugins/Npm/NpmBuild.psm1 rename to utils/plugins/Npm/NpmBuild.psm1 diff --git a/utils/src/plugins/Npm/NpmJestTest.psm1 b/utils/plugins/Npm/NpmJestTest.psm1 similarity index 100% rename from utils/src/plugins/Npm/NpmJestTest.psm1 rename to utils/plugins/Npm/NpmJestTest.psm1 diff --git a/utils/plugins/Npm/NpmPack.psm1 b/utils/plugins/Npm/NpmPack.psm1 new file mode 100644 index 0000000..5950396 --- /dev/null +++ b/utils/plugins/Npm/NpmPack.psm1 @@ -0,0 +1,119 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Packs npm workspace packages into .tgz release artifacts. + +.DESCRIPTION + Runs npm pack for each configured workspace package and publishes the + resulting tarball paths into shared release context (releaseAssetPaths) + for the GitHub release plugin. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "ScriptConfig" -RequiredCommand "Assert-Command" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-RelativePaths" + + $pluginSettings = $Settings + $shared = $Settings.context + + Assert-Command npm + + $workspaceRoot = $null + if ($pluginSettings.workspaceRoot) { + $workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir) + $workspaceRoot = $workspaceRoots[0] + } + elseif ($shared.PSObject.Properties['npmWorkspaceRoot'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.npmWorkspaceRoot)) { + $workspaceRoot = [string]$shared.npmWorkspaceRoot + } + else { + throw "NpmPack plugin requires 'workspaceRoot' or a prior NpmReleaseVersion plugin run." + } + + $artifactsDirectory = $null + if ($pluginSettings.PSObject.Properties['artifactsDir'] -and -not [string]::IsNullOrWhiteSpace([string]$pluginSettings.artifactsDir)) { + $artifactsDirectory = [System.IO.Path]::GetFullPath((Join-Path $shared.scriptDir ([string]$pluginSettings.artifactsDir))) + } + elseif ($shared.PSObject.Properties['artifactsDirectory'] -and -not [string]::IsNullOrWhiteSpace([string]$shared.artifactsDirectory)) { + $artifactsDirectory = [string]$shared.artifactsDirectory + } + else { + throw "NpmPack plugin requires release-stage artifactsDirectory." + } + + $packOrder = @() + if ($pluginSettings.publishOrder) { + if ($pluginSettings.publishOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.publishOrder -is [string])) { + $packOrder = @($pluginSettings.publishOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.publishOrder)) { + $packOrder = @([string]$pluginSettings.publishOrder) + } + } + elseif ($pluginSettings.packOrder) { + if ($pluginSettings.packOrder -is [System.Collections.IEnumerable] -and -not ($pluginSettings.packOrder -is [string])) { + $packOrder = @($pluginSettings.packOrder | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) + } + elseif (-not [string]::IsNullOrWhiteSpace([string]$pluginSettings.packOrder)) { + $packOrder = @([string]$pluginSettings.packOrder) + } + } + + if ($packOrder.Count -eq 0) { + throw "NpmPack plugin requires non-empty 'publishOrder' or 'packOrder' (workspace package names)." + } + + if (-not (Test-Path $artifactsDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null + } + + $releaseAssetPaths = @() + + Push-Location $workspaceRoot + try { + foreach ($packageName in $packOrder) { + Write-Log -Level "STEP" -Message "Packing npm package '$packageName'..." + $tarballName = (npm pack -w $packageName --pack-destination $artifactsDirectory 2>$null | Select-Object -Last 1) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace([string]$tarballName)) { + throw "npm pack failed for '$packageName'." + } + + $tarballPath = Join-Path $artifactsDirectory ([string]$tarballName).Trim() + if (-not (Test-Path $tarballPath -PathType Leaf)) { + throw "Could not locate pack output for '$packageName' at: $tarballPath" + } + + Write-Log -Level "OK" -Message " Package ready: $tarballPath" + $releaseAssetPaths += $tarballPath + } + } + finally { + Pop-Location + } + + $shared | Add-Member -NotePropertyName releaseDir -NotePropertyValue $artifactsDirectory -Force + $shared | Add-Member -NotePropertyName releaseAssetPaths -NotePropertyValue $releaseAssetPaths -Force + if ($releaseAssetPaths.Count -gt 0) { + $shared | Add-Member -NotePropertyName packageFile -NotePropertyValue (Get-Item -LiteralPath $releaseAssetPaths[0]) -Force + } + + Write-Log -Level "OK" -Message " npm pack completed ($($releaseAssetPaths.Count) tarball(s))." +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/src/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1 similarity index 75% rename from utils/src/plugins/Npm/NpmPublish.psm1 rename to utils/plugins/Npm/NpmPublish.psm1 index 7ff8259..09333de 100644 --- a/utils/src/plugins/Npm/NpmPublish.psm1 +++ b/utils/plugins/Npm/NpmPublish.psm1 @@ -6,9 +6,9 @@ Publishes npm workspace packages to the npm registry. .DESCRIPTION - Publishes packages in configured order using an API key from an environment - variable (for example NPMJS_MAKS_IT). Uses a temporary .npmrc in the - workspace root for auth and supports --skip-duplicate semantics via npm. + Publishes packages in configured order using npmSecret (logical secret name). + Pass the token via an environment variable named like the configured npm secret (e.g. Npm). + Uses a temporary .npmrc in the workspace root. #> if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { @@ -32,18 +32,10 @@ function Invoke-Plugin { $pluginSettings = $Settings $shared = $Settings.context + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $shared + Assert-Command npm - $npmApiKeyEnvVar = $pluginSettings.npmApiKey - if ([string]::IsNullOrWhiteSpace($npmApiKeyEnvVar)) { - throw "NpmPublish plugin requires 'npmApiKey' in scriptSettings.json (environment variable name)." - } - - $npmApiKey = [System.Environment]::GetEnvironmentVariable($npmApiKeyEnvVar) - if ([string]::IsNullOrWhiteSpace($npmApiKey)) { - throw "npm API key is not set. Set '$npmApiKeyEnvVar' and rerun." - } - $workspaceRoot = $null if ($pluginSettings.workspaceRoot) { $workspaceRoots = @(Resolve-RelativePaths -Value $pluginSettings.workspaceRoot -BasePath $shared.scriptDir) @@ -84,11 +76,28 @@ function Invoke-Plugin { throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)." } + if ($dryRun) { + foreach ($packageName in $publishOrder) { + Write-Log -Level "INFO" -Message "Dry run: would publish npm package '$packageName' to $registry" + } + return + } + + $npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' + if ([string]::IsNullOrWhiteSpace($npmSecret)) { + throw "NpmPublish plugin requires 'npmSecret' in scriptSettings.json (logical secret name, e.g. Npm)." + } + + $npmToken = Get-SecretEnvironmentValue -Name $npmSecret + if ([string]::IsNullOrWhiteSpace($npmToken)) { + throw "npm API key is not set. Set environment variable '$npmSecret'." + } + $registryHost = ([uri]$registry).Host $tempNpmRcPath = Join-Path $workspaceRoot ".npmrc.release-temp" $npmRcContent = @" registry=$registry -//$registryHost/:_authToken=$npmApiKey +//$registryHost/:_authToken=$npmToken "@ Push-Location $workspaceRoot @@ -115,4 +124,8 @@ registry=$registry } } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/src/plugins/Npm/NpmReleaseVersion.psm1 b/utils/plugins/Npm/NpmReleaseVersion.psm1 similarity index 100% rename from utils/src/plugins/Npm/NpmReleaseVersion.psm1 rename to utils/plugins/Npm/NpmReleaseVersion.psm1 diff --git a/utils/plugins/Platform/ContainerEngineProbe.psm1 b/utils/plugins/Platform/ContainerEngineProbe.psm1 new file mode 100644 index 0000000..e03d0b1 --- /dev/null +++ b/utils/plugins/Platform/ContainerEngineProbe.psm1 @@ -0,0 +1,47 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Probes docker/podman availability and sets mandatory shared engine state for the pipeline. + +.DESCRIPTION + Must be the first enabled plugin. Sets containerEngineProbeCompleted, activeContainerEngine, + dockerEngineAvailable, podmanEngineAvailable, and compose CLI fields on shared context. + Fails the pipeline when no usable engine is found. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'ContainerEngineSupport' -RequiredCommand 'Resolve-ActiveContainerEngineState' + + $shared = $Settings.context + + Write-Log -Level 'STEP' -Message 'Probing container engine availability...' + $state = Resolve-ActiveContainerEngineState + Set-ContainerEngineSharedState -SharedSettings $shared -State $state + + $display = Get-ContainerEngineDisplayName -Engine (Get-SharedContainerEngineInvocation -SharedSettings $shared) + Write-Log -Level 'OK' -Message " Active engine: $display" + Write-Log -Level 'INFO' -Message " docker CLI available: $($state.DockerEngineAvailable); podman CLI available: $($state.PodmanEngineAvailable)" + Write-Log -Level 'INFO' -Message " Compose CLI: $($state.ComposeExecutable) $($state.ComposeBaseArgs -join ' ')" +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $false } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/src/plugins/Platform/CoverageBadges.psm1 b/utils/plugins/Platform/CoverageBadges.psm1 similarity index 100% rename from utils/src/plugins/Platform/CoverageBadges.psm1 rename to utils/plugins/Platform/CoverageBadges.psm1 diff --git a/utils/plugins/Platform/FileReleaseVersion.psm1 b/utils/plugins/Platform/FileReleaseVersion.psm1 new file mode 100644 index 0000000..e0b7457 --- /dev/null +++ b/utils/plugins/Platform/FileReleaseVersion.psm1 @@ -0,0 +1,41 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Loads release version from a repo-root VERSION file into shared context. + +.DESCRIPTION + Reads a single-line semver from the configured versionFilePath (default repo-root VERSION). + Used by container-only repos without .csproj or package.json. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" + Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Resolve-FileReleaseVersion" + + $shared = $Settings.context + $resolved = Resolve-FileReleaseVersion -Plugins @($Settings) -ScriptDir $shared.scriptDir + $versionFilePaths = @(Resolve-RelativePaths -Value $Settings.versionFilePath -BasePath $shared.scriptDir) + + $shared | Add-Member -NotePropertyName version -NotePropertyValue $resolved.version -Force + if ($versionFilePaths.Count -gt 0) { + $shared | Add-Member -NotePropertyName versionFilePath -NotePropertyValue $versionFilePaths[0] -Force + } + Write-Log -Level "OK" -Message " Release version loaded by FileReleaseVersion plugin: $($shared.version)" +} + +Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/src/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 similarity index 83% rename from utils/src/plugins/Platform/GitHub.psm1 rename to utils/plugins/Platform/GitHub.psm1 index 9af816c..4bbb561 100644 --- a/utils/src/plugins/Platform/GitHub.psm1 +++ b/utils/plugins/Platform/GitHub.psm1 @@ -98,7 +98,7 @@ function Invoke-Plugin { $pluginSettings = $Settings $sharedSettings = $Settings.context - $githubTokenEnvVar = $pluginSettings.githubToken + $githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' $configuredRepository = $pluginSettings.repository $releaseNotesFileSetting = $pluginSettings.releaseNotesFile $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern @@ -108,15 +108,37 @@ function Invoke-Plugin { $releaseDir = $sharedSettings.releaseDir $releaseAssetPaths = @() - Assert-Command gh + $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings - if ([string]::IsNullOrWhiteSpace($githubTokenEnvVar)) { - throw "GitHub plugin requires 'githubToken' in scriptSettings.json." + if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { + throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json." } - $githubToken = [System.Environment]::GetEnvironmentVariable($githubTokenEnvVar) - if ([string]::IsNullOrWhiteSpace($githubToken)) { - throw "GitHub token is not set. Set '$githubTokenEnvVar' and rerun." + $releaseNotesFile = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $releaseNotesFileSetting)) + $releaseNotes = Get-ReleaseNotesInternal -ReleaseNotesFile $releaseNotesFile -Version $version + + if ($dryRun) { + $repo = Get-GitHubRepositoryInternal -ConfiguredRepository $configuredRepository + $releaseTitlePattern = if ([string]::IsNullOrWhiteSpace($releaseTitlePatternSetting)) { + "Release {version}" + } + else { + $releaseTitlePatternSetting + } + $releaseName = $releaseTitlePattern -replace '\{version\}', $version + Write-Log -Level "INFO" -Message "Dry run: would create GitHub release '$releaseName' ($tag) on $repo" + return + } + + Assert-Command gh + + if ([string]::IsNullOrWhiteSpace($githubSecret)) { + throw "GitHub plugin requires 'githubSecret' in scriptSettings.json (logical secret name, e.g. GitHub)." + } + + $ghToken = Get-SecretEnvironmentValue -Name $githubSecret + if ([string]::IsNullOrWhiteSpace($ghToken)) { + throw "GitHub token is not set. Set environment variable '$githubSecret'." } if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { @@ -163,7 +185,7 @@ function Invoke-Plugin { Write-Log -Level "INFO" -Message " GitHub title: $releaseName" $previousGhToken = $env:GH_TOKEN - $env:GH_TOKEN = $githubToken + $env:GH_TOKEN = $ghToken try { $ghVersion = & gh --version 2>&1 @@ -171,7 +193,7 @@ function Invoke-Plugin { Write-Log -Level "INFO" -Message " gh version: $($ghVersion[0])" } - Write-Log -Level "INFO" -Message " Auth env var: $githubTokenEnvVar (set)" + Write-Log -Level "INFO" -Message " Auth secret: $githubSecret" $authArgs = @("api", "repos/$repo", "--jq", ".full_name") $authOutput = & gh @authArgs 2>&1 @@ -188,7 +210,7 @@ function Invoke-Plugin { $authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } } - throw "GitHub CLI authentication failed for repository '$repo'. Ensure '$githubTokenEnvVar' is valid and has access to this repository." + throw "GitHub CLI authentication failed for repository '$repo'. Ensure secret '$githubSecret' is valid and has access to this repository." } Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)" @@ -241,4 +263,8 @@ function Invoke-Plugin { } } -Export-ModuleMember -Function Invoke-Plugin +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/src/plugins/Platform/QualityGate.psm1 b/utils/plugins/Platform/QualityGate.psm1 similarity index 100% rename from utils/src/plugins/Platform/QualityGate.psm1 rename to utils/plugins/Platform/QualityGate.psm1 diff --git a/utils/src/plugins/Platform/ReleasePublishGuard.psm1 b/utils/plugins/Platform/ReleasePublishGuard.psm1 similarity index 95% rename from utils/src/plugins/Platform/ReleasePublishGuard.psm1 rename to utils/plugins/Platform/ReleasePublishGuard.psm1 index adfb262..f1eb5e3 100644 --- a/utils/src/plugins/Platform/ReleasePublishGuard.psm1 +++ b/utils/plugins/Platform/ReleasePublishGuard.psm1 @@ -3,7 +3,7 @@ <# .SYNOPSIS - Central gate for publish-stage plugins (DotNetDockerPush, DotNetHelmPush, GitHub, DotNetNuGet, NpmPublish). + Central gate before remote-mutation plugins (see each plugin's Get-PluginMetadata). .DESCRIPTION Place this plugin immediately before any publish plugins in scriptSettings.json. It sets @@ -86,6 +86,11 @@ function Invoke-Plugin { Write-Log -Level "STEP" -Message "Release publish guard..." + if ($shared.PSObject.Properties.Name -contains 'dryRun' -and [bool]$shared.dryRun) { + Write-Log -Level "INFO" -Message " Dry run: publish guard relaxed; publish plugins will validate only." + return + } + $allowed = @(Get-PluginBranches -Plugin $pluginSettings) if ($allowed.Count -gt 0 -and $allowed -notcontains '*' -and $allowed -notcontains $shared.currentBranch) { Invoke-NotMetInternal -Shared $shared -When $when -Reason "branch '$($shared.currentBranch)' is not in the guard branches list." diff --git a/utils/plugins/Podman/PodmanCompose.psm1 b/utils/plugins/Podman/PodmanCompose.psm1 new file mode 100644 index 0000000..5496924 --- /dev/null +++ b/utils/plugins/Podman/PodmanCompose.psm1 @@ -0,0 +1,35 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Podman Compose plugin — validate or bring up the stack via podman compose. + +.DESCRIPTION + Requires ContainerEngineProbe first. Skips safely when activeContainerEngine is docker. + Orchestrator filtering (Compose-only) applies only when MAKSIT_ORCHESTRATOR is set (CI). +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'ComposeDeploySupport' -RequiredCommand 'Invoke-ComposeStackPlugin' + Invoke-ComposeStackPlugin -Settings $Settings -RequiredEngine 'podman' -PluginName 'PodmanCompose' +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true; orchestrators = @('Compose') } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/plugins/Podman/PodmanImagePush.psm1 b/utils/plugins/Podman/PodmanImagePush.psm1 new file mode 100644 index 0000000..9ae5845 --- /dev/null +++ b/utils/plugins/Podman/PodmanImagePush.psm1 @@ -0,0 +1,44 @@ +#requires -Version 7.0 +#requires -PSEdition Core + +<# +.SYNOPSIS + Podman image publish plugin — build and push images via podman (local podman CLI). + +.DESCRIPTION + Requires ContainerEngineProbe first. Skips safely when activeContainerEngine is docker. + In CI, runs inside the spawned podman builder container (local nested podman, not CONTAINER_HOST). + Builds any Dockerfile listed in images[]; not limited to .NET projects. +#> + +if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { + $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $pluginSupportModulePath = Join-Path $srcDir 'modules/Engine/PluginSupport.psm1' + if (Test-Path $pluginSupportModulePath -PathType Leaf) { + Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop + } +} + +function Invoke-Plugin { + param( + [Parameter(Mandatory = $true)] + $Settings + ) + + Import-PluginDependency -ModuleName 'Logging' -RequiredCommand 'Write-Log' + Import-PluginDependency -ModuleName 'ContainerEngineSupport' -RequiredCommand 'Test-ShouldRunContainerEnginePlugin' + Import-PluginDependency -ModuleName 'ContainerImagePushSupport' -RequiredCommand 'Invoke-ContainerImagePush' + + $shared = $Settings.context + if (-not (Test-ShouldRunContainerEnginePlugin -RequiredEngine 'podman' -SharedSettings $shared -PluginName 'PodmanImagePush')) { + return + } + + Invoke-ContainerImagePush -Settings $Settings -PluginDisplayName 'PodmanImagePush' +} + +function Get-PluginMetadata { + [pscustomobject]@{ mutatesRemote = $true } +} + +Export-ModuleMember -Function Invoke-Plugin, Get-PluginMetadata diff --git a/utils/src/Invoke-ReleasePackage.bat b/utils/src/Invoke-ReleasePackage.bat deleted file mode 100644 index 85d776a..0000000 --- a/utils/src/Invoke-ReleasePackage.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\release\Invoke-ReleasePackage.ps1" %* -pause diff --git a/utils/src/Invoke-TestEngine.bat b/utils/src/Invoke-TestEngine.bat deleted file mode 100644 index 0cfd13f..0000000 --- a/utils/src/Invoke-TestEngine.bat +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0engines\test\Invoke-TestEngine.ps1" %* -pause diff --git a/utils/src/engines/release/custom/.gitkeep b/utils/src/engines/release/custom/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/utils/src/engines/release/custom/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/utils/src/engines/release/scriptSettings.json b/utils/src/engines/release/scriptSettings.json deleted file mode 100644 index 4848d5a..0000000 --- a/utils/src/engines/release/scriptSettings.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft-07/schema", - "title": "Release Package Script Settings", - "description": "maksit-webui: npm workspace publish to npmjs. ReleasePublishGuard (before NpmPublish) controls allowed branches and tag rules. Set NPMJS_MAKS_IT env var to the npm automation token (same pattern as NUGET_MAKS_IT).", - "plugins": [ - { - "name": "NpmReleaseVersion", - "stageLabel": "build", - "enabled": true, - "packageJsonPath": "..\\..\\..\\..\\src\\package.json", - "syncWorkspaceVersions": true - }, - { - "name": "NpmBuild", - "stageLabel": "build", - "enabled": true, - "workspaceRoot": "..\\..\\..\\..\\src", - "useCi": true, - "buildScript": "build" - }, - { - "name": "ReleasePublishGuard", - "stageLabel": "release", - "enabled": true, - "branches": [ - "main" - ], - "requireExactTagOnHead": true, - "tagVersionMustMatchReleaseVersion": true, - "whenRequirementsNotMet": "skip", - "requireCleanWorkingTree": false, - "ensureTagOnRemote": true, - "remoteName": "origin" - }, - { - "name": "NpmPublish", - "stageLabel": "release", - "enabled": true, - "npmApiKey": "NPMJS_MAKS_IT", - "registry": "https://registry.npmjs.org", - "access": "public", - "workspaceRoot": "..\\..\\..\\..\\src", - "publishOrder": [ - "@maks-it.com/webui-contracts", - "@maks-it.com/webui-core", - "@maks-it.com/webui-components" - ] - } - ], - "_comments": { - "plugins": { - "NpmReleaseVersion": "Reads version from src/package.json (workspace root). syncWorkspaceVersions aligns packages/*/package.json before build/publish.", - "NpmBuild": "Runs npm ci + npm run build in workspaceRoot.", - "ReleasePublishGuard": "Place before NpmPublish. tagVersionMustMatchReleaseVersion compares git tag on HEAD to NpmReleaseVersion.", - "NpmPublish": "npmApiKey is the environment variable name holding the npm automation token (NPMJS_MAKS_IT). publishOrder must follow dependency order.", - "maksit-repoutils": "Engine docs: https://github.com/MAKS-IT-COM/maksit-repoutils (src/engines/release/README.md)." - } - } -} diff --git a/utils/src/modules/ScriptConfig.psm1 b/utils/src/modules/ScriptConfig.psm1 deleted file mode 100644 index 26bd953..0000000 --- a/utils/src/modules/ScriptConfig.psm1 +++ /dev/null @@ -1,35 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -function Get-ScriptSettings { - param( - [Parameter(Mandatory = $true)] - [string]$ScriptDir, - - [Parameter(Mandatory = $false)] - [string]$SettingsFileName = "scriptSettings.json" - ) - - $settingsPath = Join-Path $ScriptDir $SettingsFileName - - if (-not (Test-Path $settingsPath -PathType Leaf)) { - Write-Error "Settings file not found: $settingsPath" - exit 1 - } - - return Get-Content $settingsPath -Raw | ConvertFrom-Json -} - -function Assert-Command { - param( - [Parameter(Mandatory = $true)] - [string]$Command - ) - - if (-not (Get-Command $Command -ErrorAction SilentlyContinue)) { - Write-Error "Required command '$Command' is missing. Aborting." - exit 1 - } -} - -Export-ModuleMember -Function Get-ScriptSettings, Assert-Command diff --git a/utils/src/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 b/utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 similarity index 100% rename from utils/src/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 rename to utils/tools/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1 diff --git a/utils/src/tools/Force-AmendTaggedCommit/scriptSettings.json b/utils/tools/Force-AmendTaggedCommit/scriptSettings.json similarity index 100% rename from utils/src/tools/Force-AmendTaggedCommit/scriptSettings.json rename to utils/tools/Force-AmendTaggedCommit/scriptSettings.json diff --git a/utils/src/tools/Update-RepoUtils/Update-RepoUtils.ps1 b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 similarity index 98% rename from utils/src/tools/Update-RepoUtils/Update-RepoUtils.ps1 rename to utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 index 80082c3..9548ede 100644 --- a/utils/src/tools/Update-RepoUtils/Update-RepoUtils.ps1 +++ b/utils/tools/Update-RepoUtils/Update-RepoUtils.ps1 @@ -248,6 +248,11 @@ try { } if (-not $dryRun) { + $backupDirectory = Split-Path -Parent $backupPath + if (-not (Test-Path -Path $backupDirectory -PathType Container)) { + New-Item -ItemType Directory -Path $backupDirectory -Force | Out-Null + } + Copy-Item -Path $file.FullName -Destination $backupPath -Force } } diff --git a/utils/src/tools/Update-RepoUtils/scriptSettings.json b/utils/tools/Update-RepoUtils/scriptSettings.json similarity index 100% rename from utils/src/tools/Update-RepoUtils/scriptSettings.json rename to utils/tools/Update-RepoUtils/scriptSettings.json