From 0ee07f724689d042b9e195bc63d7c2ea09251c25 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Thu, 3 Sep 2026 08:04:09 +0200 Subject: [PATCH] (feature): persist operator settings in appdata; sync repoutils secret packs --- CHANGELOG.md | 8 + README.md | 4 +- src/Directory.Build.props | 2 +- .../ConfigurationFileService.cs | 64 +- .../UserSettingsPath.cs | 17 + .../appsettings.json | 17 - .../ConfigurationFileServiceTests.cs | 71 +- src/MaksIT.ClusterConsole.UI/App.axaml.cs | 4 + utils/engines/release/scriptSettings.json | 7 +- utils/engines/test/scriptSettings.json | 5 +- utils/modules/Engine/PluginSupport.psm1 | 615 +++++++++++++++--- utils/modules/Engine/ReleaseSupport.psm1 | 1 + utils/modules/Engine/TestSupport.psm1 | 1 + utils/modules/Engine/VaultSupport.psm1 | 168 ++--- utils/plugins/Desktop/DesktopPackSupport.psm1 | 52 +- utils/plugins/Desktop/WindowsInstaller.psm1 | 18 +- .../DotNet/DotNetCleanupArtifacts.psm1 | 122 ---- utils/plugins/DotNet/DotNetNuGet.psm1 | 10 +- utils/plugins/Npm/NpmPublish.psm1 | 14 +- utils/plugins/Platform/GitHub.psm1 | 12 +- 20 files changed, 838 insertions(+), 374 deletions(-) create mode 100644 src/MaksIT.ClusterConsole.Shared/UserSettingsPath.cs delete mode 100644 utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 587db3a..3605451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.6.2] - 2026-09-03 + +### Changed + +- Operator settings (layout, port-forwards, Chat) are written to `%AppData%/MaksIT/Cluster Console/settings.json` (WiX `installFolderName`). Shipped `appsettings.json` next to the exe keeps host logging only; a leftover `Configuration` block is copied once into the user file. +- Synced RepoUtils: ContainerRegistry JSON catalog (PascalCase Harbor / InCluster keys) and `RepoUtilsSecrets` pack slots instead of per-plugin `*Secret` env names. + ## [0.6.1] - 2026-08-30 ### Changed @@ -133,3 +140,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Helm releases, Dapr CRDs, Applications view, force-delete, volume file browse, resource-limit patches, and a read-only local Ollama **Chat** tab. See [README.md](README.md) for the full feature list. + diff --git a/README.md b/README.md index 7985b9f..65c57cc 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,9 @@ Connect a context from the catalog, pick a navigator item, then use the table, d ## Configuration -Defaults live in `src/MaksIT.ClusterConsole.Shared/appsettings.json` (copied next to the UI). Notable keys under `Configuration`: +Host logging lives in `src/MaksIT.ClusterConsole.Shared/appsettings.json` (copied next to the UI under Program Files; normal users cannot write it). Operator layout, open clusters, port-forwards, and Chat settings are saved to `%AppData%/MaksIT/Cluster Console/settings.json` (same folder name as WiX: `Program Files\MaksIT\Cluster Console`). On first launch, a leftover `Configuration` block next to the exe is copied once into that user file. + +Notable keys under `Configuration` in the user file: | Key | Role | |-----|------| diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 110de5f..b75f6fe 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.6.1 + 0.6.2 MaksIT.ClusterConsole MaksIT.ClusterConsole diff --git a/src/MaksIT.ClusterConsole.Shared/ConfigurationFileService.cs b/src/MaksIT.ClusterConsole.Shared/ConfigurationFileService.cs index f147614..08251ef 100644 --- a/src/MaksIT.ClusterConsole.Shared/ConfigurationFileService.cs +++ b/src/MaksIT.ClusterConsole.Shared/ConfigurationFileService.cs @@ -5,20 +5,36 @@ using System.Text.Json.Nodes; namespace MaksIT.ClusterConsole.Shared; public sealed class ConfigurationFileService { + public const string ProductFolder = "Cluster Console"; + public const string SeedFileName = "appsettings.json"; + private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true, PropertyNamingPolicy = null }; + private readonly string? _seedPath; private Configuration _current; public string FilePath { get; } public Configuration Current => _current; - public ConfigurationFileService(string? configurationPath = null) { - FilePath = configurationPath ?? Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + public ConfigurationFileService(string? configurationPath = null, string? seedPath = null) { + if (!string.IsNullOrWhiteSpace(configurationPath)) + FilePath = configurationPath; + else + FilePath = UserSettingsPath.Get(ProductFolder); + + if (!string.IsNullOrWhiteSpace(seedPath)) + _seedPath = seedPath; + else if (string.IsNullOrWhiteSpace(configurationPath)) + _seedPath = Path.Combine(AppContext.BaseDirectory, SeedFileName); + else + _seedPath = null; + _current = LoadFromDisk(); + CopySeedIfNeeded(); } public Configuration Reload() { @@ -29,24 +45,23 @@ public sealed class ConfigurationFileService { public void Save(Configuration configuration) { ArgumentNullException.ThrowIfNull(configuration); configuration.EnsureDefaults(); - JsonObject root; - if (File.Exists(FilePath)) { - root = JsonNode.Parse(File.ReadAllText(FilePath)) as JsonObject ?? []; - } - else { - root = []; - } + var dir = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + var root = ReadRoot(File.Exists(FilePath) ? FilePath : null) ?? []; root["Configuration"] = JsonSerializer.SerializeToNode(configuration, SerializerOptions); File.WriteAllText(FilePath, root.ToJsonString(SerializerOptions)); _current = configuration; } private Configuration LoadFromDisk() { - if (!File.Exists(FilePath)) + var path = ResolveReadPath(); + if (path is null) return new Configuration(); - using var document = JsonDocument.Parse(File.ReadAllText(FilePath)); + using var document = JsonDocument.Parse(File.ReadAllText(path)); if (!document.RootElement.TryGetProperty("Configuration", out var value)) return new Configuration(); @@ -54,4 +69,31 @@ public sealed class ConfigurationFileService { configuration.EnsureDefaults(); return configuration; } + + private static JsonObject? ReadRoot(string? path) { + if (path is null || !File.Exists(path)) + return null; + + return JsonNode.Parse(File.ReadAllText(path)) as JsonObject; + } + + private void CopySeedIfNeeded() { + if (File.Exists(FilePath) || _seedPath is null || !File.Exists(_seedPath) || !HasConfiguration(_seedPath)) + return; + + Save(_current); + } + + private static bool HasConfiguration(string path) { + using var document = JsonDocument.Parse(File.ReadAllText(path)); + return document.RootElement.TryGetProperty("Configuration", out _); + } + + private string? ResolveReadPath() { + if (File.Exists(FilePath)) + return FilePath; + if (_seedPath is not null && File.Exists(_seedPath)) + return _seedPath; + return null; + } } diff --git a/src/MaksIT.ClusterConsole.Shared/UserSettingsPath.cs b/src/MaksIT.ClusterConsole.Shared/UserSettingsPath.cs new file mode 100644 index 0000000..e581cc2 --- /dev/null +++ b/src/MaksIT.ClusterConsole.Shared/UserSettingsPath.cs @@ -0,0 +1,17 @@ +namespace MaksIT.ClusterConsole.Shared; + + +/// +/// User-writable operator settings under AppData. The product folder must match +/// the WiX installFolderName (or Get-DesktopInstallFolderName from +/// appName + manufacturer), e.g. %AppData%/MaksIT/Cluster Console. +/// Host logging stays in shipped appsettings.json next to the exe. +/// +public static class UserSettingsPath { + public static string Get(string product, string fileName = "settings.json") => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "MaksIT", + product, + fileName); +} diff --git a/src/MaksIT.ClusterConsole.Shared/appsettings.json b/src/MaksIT.ClusterConsole.Shared/appsettings.json index 70ee22d..8983e0f 100644 --- a/src/MaksIT.ClusterConsole.Shared/appsettings.json +++ b/src/MaksIT.ClusterConsole.Shared/appsettings.json @@ -5,22 +5,5 @@ "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } - }, - "Configuration": { - "SelectedNamespace": "all", - "OllamaEndpoint": "http://127.0.0.1:11434", - "OllamaModel": "qwen3:8b", - "NavigatorExpanded": {}, - "Layout": { - "WindowWidth": 1400, - "WindowHeight": 860, - "WindowState": "Normal", - "CatalogWidth": 248, - "NavigatorWidth": 228, - "DetailsWidth": 380, - "SelectedNavId": "pods", - "Tables": {} - }, - "PortForwards": [] } } diff --git a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs index d157023..c963972 100644 --- a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs +++ b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs @@ -247,9 +247,76 @@ public class ConfigurationFileServiceTests { } [Fact] - public void Default_path_is_appsettings_beside_the_executable() { + public void Default_path_is_appdata_under_maksit() { var service = new ConfigurationFileService(); - Assert.Equal(Path.Combine(AppContext.BaseDirectory, "appsettings.json"), service.FilePath); + Assert.Equal(UserSettingsPath.Get(ConfigurationFileService.ProductFolder), service.FilePath); + } + + [Fact] + public void Save_to_new_file_writes_only_configuration() { + var path = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}.json"); + try { + var service = new ConfigurationFileService(path); + service.Save(new Configuration { SelectedNamespace = "kube-system" }); + + var json = File.ReadAllText(path); + Assert.Contains("\"Configuration\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"Logging\"", json, StringComparison.Ordinal); + Assert.Equal("kube-system", new ConfigurationFileService(path).Current.SelectedNamespace); + } + finally { + if (File.Exists(path)) + File.Delete(path); + } + } + + [Fact] + public void Copies_seed_configuration_without_logging() { + var dir = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var seed = Path.Combine(dir, "appsettings.json"); + var user = Path.Combine(dir, "settings.json"); + File.WriteAllText(seed, """ + { + "Logging": { "LogLevel": { "Default": "Information" } }, + "Configuration": { "SelectedNamespace": "kube-system" } + } + """); + + try { + var service = new ConfigurationFileService(user, seed); + Assert.True(File.Exists(user)); + Assert.Equal("kube-system", service.Current.SelectedNamespace); + + var json = File.ReadAllText(user); + Assert.Contains("\"Configuration\"", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"Logging\"", json, StringComparison.Ordinal); + } + finally { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Logging_only_seed_does_not_create_user_file() { + var dir = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var seed = Path.Combine(dir, "appsettings.json"); + var user = Path.Combine(dir, "settings.json"); + File.WriteAllText(seed, """ + { + "Logging": { "LogLevel": { "Default": "Information" } } + } + """); + + try { + var service = new ConfigurationFileService(user, seed); + Assert.False(File.Exists(user)); + Assert.Equal(Configuration.AllNamespaces, service.Current.SelectedNamespace); + } + finally { + Directory.Delete(dir, true); + } } [Fact] diff --git a/src/MaksIT.ClusterConsole.UI/App.axaml.cs b/src/MaksIT.ClusterConsole.UI/App.axaml.cs index 8a48cc4..e1f6ada 100644 --- a/src/MaksIT.ClusterConsole.UI/App.axaml.cs +++ b/src/MaksIT.ClusterConsole.UI/App.axaml.cs @@ -22,6 +22,10 @@ public partial class App : Application { .ConfigureAppConfiguration(builder => { builder.SetBasePath(AppContext.BaseDirectory); builder.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); + builder.AddJsonFile( + UserSettingsPath.Get(ConfigurationFileService.ProductFolder), + optional: true, + reloadOnChange: true); }) .ConfigureServices((_, services) => { services.AddSingleton(_ => new ConfigurationFileService()); diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json index 9a9f6d1..423a98d 100644 --- a/utils/engines/release/scriptSettings.json +++ b/utils/engines/release/scriptSettings.json @@ -98,9 +98,9 @@ }, { "name": "GitHub", + "githubSecret": "GitClone", "stageLabel": "release", "enabled": true, - "githubSecret": "GitHub", "repository": "https://github.com/MAKS-IT-COM/maksit-cluster-console", "releaseNotesFile": "..\\..\\..\\CHANGELOG.md", "releaseTitlePattern": "Release {version}", @@ -119,5 +119,8 @@ "*.flatpak" ] } - ] + ], + "repoUtilsSecretsShared": "RepoUtilsSecretsShared", + "repoUtilsSecrets": "RepoUtilsSecrets", + "vaultRepoUtilsApplication": "Shared" } diff --git a/utils/engines/test/scriptSettings.json b/utils/engines/test/scriptSettings.json index 8196a49..78d29ac 100644 --- a/utils/engines/test/scriptSettings.json +++ b/utils/engines/test/scriptSettings.json @@ -46,5 +46,8 @@ "red": 0 } } - ] + ], + "repoUtilsSecretsShared": "RepoUtilsSecretsShared", + "repoUtilsSecrets": "RepoUtilsSecrets", + "vaultRepoUtilsApplication": "Shared" } diff --git a/utils/modules/Engine/PluginSupport.psm1 b/utils/modules/Engine/PluginSupport.psm1 index 1931591..2b808d4 100644 --- a/utils/modules/Engine/PluginSupport.psm1 +++ b/utils/modules/Engine/PluginSupport.psm1 @@ -294,129 +294,586 @@ function Test-PluginMutatesRemote { return $false } -function Get-SecretEnvironmentValue { +function Get-RepoUtilsEnvironmentVariable { <# .SYNOPSIS - Reads a secret value from an environment variable by logical name. + Reads a named environment variable from Process, then Windows User, then Machine. .DESCRIPTION - Plugins never store secret material in scriptSettings.json. Settings hold a - logical name (e.g. "GitHub", "NuGet"); the process environment variable with - that same name must be set before the engine runs. - - .PARAMETER Name - Logical secret name — also the environment variable name to read. - - .OUTPUTS - System.String. The environment variable value, or $null when unset. - - .EXAMPLE - $token = Get-SecretEnvironmentValue -Name 'GitHub' + Process wins (CICD sandbox inject, `$env:Name`, explicit session values). When the + current process was started before a User-level pack was set, User/Machine still + apply so laptop releases do not require a new shell. An explicit process value — + including empty JSON `{}` — shadows User/Machine. #> param( [Parameter(Mandatory = $true)] [string]$Name ) - return [Environment]::GetEnvironmentVariable($Name) -} + if ([string]::IsNullOrWhiteSpace($Name)) { + throw "Environment variable name is required." + } -function Resolve-PluginSecretName { - <# - .SYNOPSIS - Resolves a logical secret name from a plugin's scriptSettings entry. + $processValue = [Environment]::GetEnvironmentVariable($Name, 'Process') + # Empty string is what SetEnvironmentVariable($null, Process) leaves behind; + # treat it as unset so Windows User packs still apply. Explicit '{}' shadows User. + if (-not [string]::IsNullOrWhiteSpace($processValue)) { + return $processValue + } - .DESCRIPTION - Reads a string property such as githubSecret / nugetSecret / npmSecret / - containerRegistrySecret from the plugin settings object. Returns $null when - the property is missing or blank. + foreach ($target in @('User', 'Machine')) { + try { + $value = [Environment]::GetEnvironmentVariable($Name, $target) + } + catch { + continue + } - .PARAMETER PluginSettings - Plugin settings object from scriptSettings.json (the enabled plugin entry). - - .PARAMETER PropertyName - Settings property that holds the logical secret name (e.g. 'githubSecret'). - - .OUTPUTS - System.String. Trimmed logical secret name, or $null. - - .EXAMPLE - $name = Resolve-PluginSecretName -PluginSettings $plugin -PropertyName 'nugetSecret' - $key = Get-SecretEnvironmentValue -Name $name - #> - param( - [Parameter(Mandatory = $true)] - $PluginSettings, - - [Parameter(Mandatory = $true)] - [string]$PropertyName - ) - - if ($PluginSettings.PSObject.Properties.Name -contains $PropertyName) { - $value = [string]$PluginSettings.$PropertyName if (-not [string]::IsNullOrWhiteSpace($value)) { - return $value.Trim() + return $value } } return $null } +function Get-RepoUtilsSecretsEnvNames { + <# + .SYNOPSIS + Reads declared pack environment variable names from scriptSettings / engine context. + #> + param( + [Parameter(Mandatory = $false)] + $Settings + ) + + $sharedName = $null + $packName = $null + if ($null -ne $Settings) { + if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecretsShared') { + $sharedName = [string]$Settings.repoUtilsSecretsShared + } + + if ($Settings.PSObject.Properties.Name -contains 'repoUtilsSecrets') { + $packName = [string]$Settings.repoUtilsSecrets + } + } + + $sharedName = if ($null -eq $sharedName) { '' } else { $sharedName.Trim() } + $packName = if ($null -eq $packName) { '' } else { $packName.Trim() } + if ([string]::IsNullOrWhiteSpace($sharedName) -or [string]::IsNullOrWhiteSpace($packName)) { + throw "scriptSettings.json must declare repoUtilsSecretsShared and repoUtilsSecrets (environment variable names, e.g. RepoUtilsSecretsShared / RepoUtilsSecrets)." + } + + return [pscustomobject]@{ + SharedEnv = $sharedName + PackEnv = $packName + } +} + +function ConvertFrom-RepoUtilsSecretsPackJson { + <# + .SYNOPSIS + Parses a RepoUtilsSecrets JSON object. Empty input is {}. Bare non-JSON throws. + #> + param( + [Parameter(Mandatory = $false)] + [AllowEmptyString()] + [string]$Raw, + + [Parameter(Mandatory = $true)] + [string]$SourceName + ) + + if ([string]::IsNullOrWhiteSpace($Raw)) { + return [pscustomobject]@{} + } + + $trimmed = $Raw.Trim() + if (-not $trimmed.StartsWith('{')) { + throw "${SourceName} must be a JSON object (RepoUtilsSecrets pack), not a bare string." + } + + try { + $parsed = $trimmed | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "${SourceName} is not valid JSON: $($_.Exception.Message)" + } + + if ($null -eq $parsed -or $parsed -is [System.Collections.IEnumerable]) { + throw "${SourceName} JSON must be an object." + } + + return $parsed +} + +function ConvertTo-OrdinalPropertyMap { + param($Object) + + $map = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + if ($null -eq $Object) { + return $map + } + + if ($Object -is [System.Collections.IDictionary]) { + foreach ($key in @($Object.Keys)) { + $name = [string]$key + if ([string]::IsNullOrWhiteSpace($name)) { + continue + } + + $map[$name] = $Object[$key] + } + + return $map + } + + foreach ($property in $Object.PSObject.Properties) { + if ($property.MemberType -notin @('NoteProperty', 'Property')) { + continue + } + + $map[[string]$property.Name] = $property.Value + } + + return $map +} + +function ConvertFrom-OrdinalPropertyMap { + param( + [Parameter(Mandatory = $true)] + [System.Collections.Generic.Dictionary[string, object]]$Map + ) + + $properties = [ordered]@{} + foreach ($key in $Map.Keys) { + $properties[$key] = $Map[$key] + } + + return [pscustomobject]$properties +} + +function Merge-RepoUtilsSecretsPackObjects { + <# + .SYNOPSIS + Appsettings-style merge: org-pack first, slug overlay. Nested merge for any object-valued slot (typically ContainerRegistry). + #> + param( + $Base, + $Overlay + ) + + $result = ConvertTo-OrdinalPropertyMap -Object $Base + $overlayMap = ConvertTo-OrdinalPropertyMap -Object $Overlay + foreach ($key in @($overlayMap.Keys)) { + if (-not (Test-ContainerRegistryCatalogKey -Key $key)) { + throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)." + } + + $overlayValue = $overlayMap[$key] + $overlayIsObject = ($null -ne $overlayValue) -and -not ($overlayValue -is [string]) -and -not ($overlayValue -is [ValueType]) -and -not ($overlayValue -is [System.Collections.IEnumerable]) + if ($overlayIsObject) { + $baseCatalog = $null + if ($result.ContainsKey($key)) { + $baseCatalog = $result[$key] + } + + $mergedCatalog = ConvertTo-OrdinalPropertyMap -Object $baseCatalog + $overlayCatalogMap = ConvertTo-OrdinalPropertyMap -Object $overlayValue + foreach ($catalogKey in @($overlayCatalogMap.Keys)) { + if (-not (Test-ContainerRegistryCatalogKey -Key $catalogKey)) { + throw "Catalog key '$catalogKey' in pack slot '$key' must be PascalCase (e.g. Harbor, InCluster)." + } + + $mergedCatalog[$catalogKey] = $overlayCatalogMap[$catalogKey] + } + + $result[$key] = ConvertFrom-OrdinalPropertyMap -Map $mergedCatalog + continue + } + + $result[$key] = $overlayValue + } + + foreach ($key in @($result.Keys)) { + if (-not (Test-ContainerRegistryCatalogKey -Key $key)) { + throw "RepoUtilsSecrets pack key '$key' must be PascalCase (e.g. GitClone, ContainerRegistry)." + } + } + + return ConvertFrom-OrdinalPropertyMap -Map $result +} + +function Get-MergedRepoUtilsSecretsPack { + <# + .SYNOPSIS + Merges $env:RepoUtilsSecretsShared then $env:RepoUtilsSecrets (names from settings). + #> + param( + [Parameter(Mandatory = $false)] + $Settings + ) + + $names = Get-RepoUtilsSecretsEnvNames -Settings $Settings + $sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv + $packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv + $sharedObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv + $packObject = ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv + return Merge-RepoUtilsSecretsPackObjects -Base $sharedObject -Overlay $packObject +} + +function Get-RepoUtilsSecretSlot { + <# + .SYNOPSIS + Reads a scalar pack slot (GitClone, NuGet, Npm, CosignKey, …) after merge. + #> + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $false)] + $Settings, + + [switch]$AllowMissing + ) + + if ([string]::IsNullOrWhiteSpace($Name) -or -not (Test-ContainerRegistryCatalogKey -Key $Name)) { + throw "RepoUtilsSecrets slot '$Name' must be PascalCase (e.g. GitClone, NuGet)." + } + + $merged = Get-MergedRepoUtilsSecretsPack -Settings $Settings + $match = $null + foreach ($property in $merged.PSObject.Properties) { + if ($property.MemberType -notin @('NoteProperty', 'Property')) { + continue + } + + if ([string]::Equals([string]$property.Name, $Name, [System.StringComparison]::Ordinal)) { + $match = $property + break + } + } + + if ($null -eq $match) { + if ($AllowMissing) { + return $null + } + + throw "RepoUtilsSecrets slot '$Name' is missing after merging org-pack and slug-pack." + } + + $value = $match.Value + if ($value -is [string] -or $null -eq $value -or $value -is [ValueType]) { + $text = if ($null -eq $value) { '' } else { [string]$value } + if ([string]::IsNullOrWhiteSpace($text) -and -not $AllowMissing) { + throw "RepoUtilsSecrets slot '$Name' is empty." + } + + if ([string]::IsNullOrWhiteSpace($text)) { + return $null + } + + return $text + } + + throw "RepoUtilsSecrets slot '$Name' must be a string (nested maps are only allowed for ContainerRegistry)." +} + +function Copy-RepoUtilsSecretsEnvNamesToContext { + param( + [Parameter(Mandatory = $true)] + $Context, + + [Parameter(Mandatory = $false)] + $Settings + ) + + if ($null -eq $Settings) { + return $Context + } + + foreach ($name in @('repoUtilsSecretsShared', 'repoUtilsSecrets', 'vaultRepoUtilsApplication')) { + if ($Settings.PSObject.Properties.Name -contains $name -and -not [string]::IsNullOrWhiteSpace([string]$Settings.$name)) { + $Context | Add-Member -NotePropertyName $name -NotePropertyValue ([string]$Settings.$name).Trim() -Force + } + } + + return $Context +} + +function Test-ContainerRegistryCatalogKey { + <# + .SYNOPSIS + True when Key is PascalCase (Harbor, InCluster), matching env-slot names. + #> + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Key + ) + + return $Key -cmatch '^[A-Z][A-Za-z0-9]*$' +} + +function Resolve-PluginSecretName { + <# + .SYNOPSIS + Reads a plugin setting that names a RepoUtilsSecrets pack subkey. + + .DESCRIPTION + Settings such as githubSecret / nugetSecret / npmSecret / containerRegistrySecret / + cosignKeySecret hold the PascalCase pack slot to read (e.g. GitClone, NuGet). + They are not environment variable names. + + .PARAMETER PluginSettings + Plugin settings object from scriptSettings.json. + + .PARAMETER PropertyName + Settings property that holds the pack subkey name (e.g. 'githubSecret'). + + .PARAMETER PluginDisplayName + Plugin name used in required/validation errors. + + .PARAMETER Required + Throw when the property is missing or blank. + #> + param( + [Parameter(Mandatory = $true)] + $PluginSettings, + + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $false)] + [string]$PluginDisplayName, + + [switch]$Required + ) + + $display = if ([string]::IsNullOrWhiteSpace($PluginDisplayName)) { 'Plugin' } else { $PluginDisplayName } + $value = $null + if ($null -ne $PluginSettings -and $PluginSettings.PSObject.Properties.Name -contains $PropertyName) { + $raw = [string]$PluginSettings.$PropertyName + if (-not [string]::IsNullOrWhiteSpace($raw)) { + $value = $raw.Trim() + } + } + + if ([string]::IsNullOrWhiteSpace($value)) { + if ($Required) { + throw "$display requires '$PropertyName' (PascalCase pack subkey, e.g. GitClone, NuGet, ContainerRegistry)." + } + + return $null + } + + if (-not (Test-ContainerRegistryCatalogKey -Key $value)) { + throw "$display '$PropertyName' '$value' must be PascalCase (e.g. GitClone, NuGet, ContainerRegistry)." + } + + return $value +} + +function Assert-RetiredContainerRegistrySettingsAbsent { + <# + .SYNOPSIS + Throws when obsolete per-registry secret settings are present. + #> + param( + $Object, + [Parameter(Mandatory = $true)] + [string]$Context + ) + + if ($null -eq $Object) { + return + } + + $retired = @( + 'imagesCredentialsSecret', + 'additionalImageRegistries', + 'additionalImageRegistryUrls', + 'additionalImagesCredentialsSecret', + 'helmOciCredentialsSecret' + ) + + foreach ($name in $retired) { + $present = $false + if ($Object -is [System.Collections.IDictionary]) { + $present = $Object.Contains($name) + } + elseif ($Object.PSObject.Properties.Name -contains $name) { + $present = $true + } + + if ($present) { + throw "${Context}: '$name' is not supported. Use the ContainerRegistry JSON catalog with PascalCase containerRegistryKey / helmRegistryKey." + } + } +} + +function ConvertFrom-RegistryCredentialPayload { + param( + [Parameter(Mandatory = $true)] + [string]$Payload, + + [Parameter(Mandatory = $true)] + [string]$ContextName + ) + + try { + $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload.Trim())) + } + catch { + throw "Failed to decode '$ContextName' 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 '$ContextName' must be in the form 'username:password'." + } + + return @{ User = $parts[0]; Password = $parts[1] } +} + +function Get-ContainerRegistryCatalogObject { + <# + .SYNOPSIS + Validates a PascalCase JSON map of Base64(username:password) values. + #> + param( + [Parameter(Mandatory = $true)] + $Catalog, + + [Parameter(Mandatory = $false)] + [string]$SourceName = 'ContainerRegistry' + ) + + if ($Catalog -is [string]) { + $raw = [string]$Catalog + $trimmed = $raw.Trim() + if (-not $trimmed.StartsWith('{')) { + throw "$SourceName must be a JSON catalog object { `"Harbor`": `"`", `"InCluster`": `"`" }." + } + + try { + $Catalog = $trimmed | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "$SourceName is not valid JSON: $($_.Exception.Message)" + } + } + + if ($null -eq $Catalog -or $Catalog -is [string] -or $Catalog -is [ValueType] -or $Catalog -is [System.Collections.IEnumerable]) { + throw "$SourceName JSON catalog must be an object of PascalCase keys to Base64(username:password)." + } + + $keyCount = 0 + foreach ($property in $Catalog.PSObject.Properties) { + if ($property.MemberType -notin @('NoteProperty', 'Property')) { + continue + } + + $keyCount++ + $keyName = [string]$property.Name + if (-not (Test-ContainerRegistryCatalogKey -Key $keyName)) { + throw "Catalog key '$keyName' in '$SourceName' must be PascalCase (e.g. Harbor, InCluster)." + } + } + + if ($keyCount -eq 0) { + throw "$SourceName JSON catalog has no PascalCase keys." + } + + return $Catalog +} + function Get-RegistryCredentialsFromRuntime { <# .SYNOPSIS - Loads container-registry username/password from a logical secret name. + Loads container-registry username/password from the merged RepoUtilsSecrets pack. .DESCRIPTION - Looks up the environment variable named by SecretName. The value must be - Base64(UTF8('username:password')). Used by registry login and image-pull - secret creation — never pass the password itself as a parameter. + Reads a nested catalog slot (named by -Slot, typically ContainerRegistry) after + org-pack + slug-pack merge. -Key (PascalCase, ordinal match) selects an entry. - .PARAMETER SecretName - Logical secret name (environment variable name), not a password or token. + .PARAMETER Key + PascalCase catalog key (e.g. Harbor). Required. + + .PARAMETER Slot + PascalCase pack subkey that holds the catalog object (from containerRegistrySecret). .PARAMETER SharedSettings - Optional engine shared context (reserved for callers that thread context). + Engine shared context (must declare repoUtilsSecretsShared / repoUtilsSecrets). .OUTPUTS Hashtable with User and Password keys (decoded credential material). - - .EXAMPLE - $creds = Get-RegistryCredentialsFromRuntime -SecretName 'ContainerRegistry' - # $creds.User / $creds.Password #> - # SecretName is a logical env-var name from scriptSettings, not a password value. - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSAvoidUsingPlainTextForPassword', - 'SecretName', - Justification = 'Logical secret name for env lookup (Base64 username:password); not a credential value.' - )] param( [Parameter(Mandatory = $true)] - [string]$SecretName, + [string]$Key, + + [Parameter(Mandatory = $true)] + [string]$Slot, [Parameter(Mandatory = $false)] [psobject]$SharedSettings ) - $raw = Get-SecretEnvironmentValue -Name $SecretName - if ([string]::IsNullOrWhiteSpace($raw)) { - throw "Environment variable '$SecretName' is not set." + if ([string]::IsNullOrWhiteSpace($Slot) -or -not (Test-ContainerRegistryCatalogKey -Key $Slot)) { + throw "containerRegistrySecret '$Slot' must be PascalCase (e.g. ContainerRegistry)." } - try { - $decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) - } - catch { - throw "Failed to decode '$SecretName' as Base64 (expected base64('username:password')): $($_.Exception.Message)" + if ([string]::IsNullOrWhiteSpace($Key)) { + throw "Pass -Key (PascalCase, e.g. Harbor) to select an entry in pack slot '$Slot'." } - $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'." + if (-not (Test-ContainerRegistryCatalogKey -Key $Key)) { + throw "containerRegistryKey '$Key' must be PascalCase (e.g. Harbor, InCluster)." } - return @{ User = $parts[0]; Password = $parts[1] } + $merged = Get-MergedRepoUtilsSecretsPack -Settings $SharedSettings + $catalogProperty = $null + foreach ($property in $merged.PSObject.Properties) { + if ($property.MemberType -notin @('NoteProperty', 'Property')) { + continue + } + + if ([string]::Equals([string]$property.Name, $Slot, [System.StringComparison]::Ordinal)) { + $catalogProperty = $property + break + } + } + + if ($null -eq $catalogProperty -or $null -eq $catalogProperty.Value) { + throw "RepoUtilsSecrets slot '$Slot' is missing after merging org-pack and slug-pack." + } + + $catalog = Get-ContainerRegistryCatalogObject -Catalog $catalogProperty.Value -SourceName $Slot + + $match = $null + foreach ($property in $catalog.PSObject.Properties) { + if ($property.MemberType -notin @('NoteProperty', 'Property')) { + continue + } + + if ([string]::Equals([string]$property.Name, $Key, [System.StringComparison]::Ordinal)) { + $match = $property + break + } + } + + if ($null -eq $match) { + throw "Catalog key '$Key' was not found in '$Slot' (ordinal PascalCase match)." + } + + $payload = [string]$match.Value + if ([string]::IsNullOrWhiteSpace($payload)) { + throw "Catalog key '$Key' in '$Slot' is empty." + } + + return ConvertFrom-RegistryCredentialPayload -Payload $payload -ContextName "$Slot.$Key" } function Resolve-EngineDirectoryFromSharedSettings { @@ -757,4 +1214,4 @@ function Invoke-ConfiguredPlugin { } } -Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Resolve-PluginSecretName, Get-SecretEnvironmentValue, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin +Export-ModuleMember -Function Import-PluginDependency, Get-ConfiguredPlugins, Get-PluginStageLabel, Get-PluginBranches, Get-PluginMetadataObject, Test-PluginCompatible, Test-PluginMutatesRemote, Get-RepoUtilsEnvironmentVariable, Get-RepoUtilsSecretsEnvNames, ConvertFrom-RepoUtilsSecretsPackJson, Merge-RepoUtilsSecretsPackObjects, Get-MergedRepoUtilsSecretsPack, Get-RepoUtilsSecretSlot, Copy-RepoUtilsSecretsEnvNamesToContext, Resolve-PluginSecretName, Test-ContainerRegistryCatalogKey, Assert-RetiredContainerRegistrySettingsAbsent, Get-ContainerRegistryCatalogObject, Get-RegistryCredentialsFromRuntime, Test-PluginSkipsRemoteMutation, Test-IsPublishPlugin, Get-PluginSettingValue, Get-PluginPathListSetting, Get-PluginPathSetting, Get-ArchiveNamePattern, Resolve-PluginModulePath, Test-PluginRunnable, New-PluginInvocationSettings, Invoke-ConfiguredPlugin diff --git a/utils/modules/Engine/ReleaseSupport.psm1 b/utils/modules/Engine/ReleaseSupport.psm1 index 0de88e4..6dc2ffb 100644 --- a/utils/modules/Engine/ReleaseSupport.psm1 +++ b/utils/modules/Engine/ReleaseSupport.psm1 @@ -213,6 +213,7 @@ function New-EngineContext { skipPublishPlugins = $false facts = [ordered]@{} } + $context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings $versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir $version = [string](Get-EngineState -Context $context -Name 'version' -Required) diff --git a/utils/modules/Engine/TestSupport.psm1 b/utils/modules/Engine/TestSupport.psm1 index b49b60b..da06af2 100644 --- a/utils/modules/Engine/TestSupport.psm1 +++ b/utils/modules/Engine/TestSupport.psm1 @@ -38,6 +38,7 @@ function New-EngineContext { utilsDir = $SrcDir facts = [ordered]@{} } + $context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings $expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue if ($expandContext) { diff --git a/utils/modules/Engine/VaultSupport.psm1 b/utils/modules/Engine/VaultSupport.psm1 index 448876b..c4226d3 100644 --- a/utils/modules/Engine/VaultSupport.psm1 +++ b/utils/modules/Engine/VaultSupport.psm1 @@ -86,89 +86,23 @@ function Get-RepoUtilsVaultConnectionSecretName { return 'MAKSIT_VAULT' } -function Add-RepoUtilsSecretNamesFromObject { - param( - $Object, - [Parameter(Mandatory = $true)] - [AllowEmptyCollection()] - [System.Collections.Generic.HashSet[string]]$Names - ) - - if ($null -eq $Object -or $Object -is [string] -or $Object -is [ValueType]) { - return - } - - if ($Object -is [System.Collections.IDictionary]) { - foreach ($key in @($Object.Keys)) { - $name = [string]$key - $value = $Object[$key] - if ($name -like '*Secret') { - $trimmed = ([string]$value).Trim() - if (-not [string]::IsNullOrWhiteSpace($trimmed) -and - -not [string]::Equals($trimmed, 'WebhookSecret', [System.StringComparison]::Ordinal)) { - [void]$Names.Add($trimmed) - } - } - else { - Add-RepoUtilsSecretNamesFromObject -Object $value -Names $Names - } - } - - return - } - - if ($Object -is [System.Collections.IEnumerable]) { - foreach ($item in @($Object)) { - Add-RepoUtilsSecretNamesFromObject -Object $item -Names $Names - } - - return - } - - if ($null -eq $Object.PSObject) { - return - } - - foreach ($property in $Object.PSObject.Properties) { - if ($property.MemberType -notin @('NoteProperty', 'Property')) { - continue - } - - $value = $property.Value - if ($property.Name -like '*Secret') { - $trimmed = ([string]$value).Trim() - if (-not [string]::IsNullOrWhiteSpace($trimmed) -and - -not [string]::Equals($trimmed, 'WebhookSecret', [System.StringComparison]::Ordinal)) { - [void]$Names.Add($trimmed) - } - - continue - } - - Add-RepoUtilsSecretNamesFromObject -Object $value -Names $Names - } -} - -function Get-EnabledPluginSecretNames { +function Get-RepoUtilsVaultOrgPackApplicationName { param( [Parameter(Mandatory = $true)] - $Plugins + $Settings ) - $names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) - foreach ($plugin in @($Plugins)) { - if ($null -eq $plugin) { - continue - } - - if (($plugin.PSObject.Properties.Name -contains 'enabled') -and ($plugin.enabled -eq $false)) { - continue - } - - Add-RepoUtilsSecretNamesFromObject -Object $plugin -Names $names + $application = $null + if ($Settings.PSObject.Properties.Name -contains 'vaultRepoUtilsApplication') { + $application = [string]$Settings.vaultRepoUtilsApplication } - return @($names) + $application = if ($null -eq $application) { '' } else { $application.Trim() } + if ([string]::IsNullOrWhiteSpace($application)) { + throw "useVault is true but vaultRepoUtilsApplication is not set in scriptSettings.json (Vault application for the org-pack, e.g. Shared)." + } + + return $application } function Get-VaultNameFilterExpression { @@ -325,25 +259,23 @@ function Resolve-RepoUtilsVaultSecretValue { [string]$Mode ) - foreach ($application in @($ApplicationName, 'Shared')) { - $match = Find-RepoUtilsVaultSecretMatch ` - -OrganizationName $OrganizationName ` - -ApplicationName $application ` - -SecretName $SecretName ` - -Connection $Connection ` - -Mode $Mode - if ($null -eq $match) { - continue - } - - $value = Get-RepoUtilsVaultSecretValue -Match $match -Connection $Connection -Mode $Mode - if (-not [string]::IsNullOrWhiteSpace($value)) { - Write-Log -Level 'INFO' -Message "Resolved Vault secret '$SecretName' from $OrganizationName/$application" - return $value - } + $match = Find-RepoUtilsVaultSecretMatch ` + -OrganizationName $OrganizationName ` + -ApplicationName $ApplicationName ` + -SecretName $SecretName ` + -Connection $Connection ` + -Mode $Mode + if ($null -eq $match) { + return $null } - return $null + $value = Get-RepoUtilsVaultSecretValue -Match $match -Connection $Connection -Mode $Mode + if ([string]::IsNullOrWhiteSpace($value)) { + return $null + } + + Write-Log -Level 'INFO' -Message "Resolved Vault secret '$SecretName' from $OrganizationName/$ApplicationName" + return $value } function Initialize-RepoUtilsVaultSecrets { @@ -359,6 +291,18 @@ function Initialize-RepoUtilsVaultSecrets { return } + $names = Get-RepoUtilsSecretsEnvNames -Settings $Settings + $sharedRaw = Get-RepoUtilsEnvironmentVariable -Name $names.SharedEnv + $packRaw = Get-RepoUtilsEnvironmentVariable -Name $names.PackEnv + $sharedPresent = -not [string]::IsNullOrWhiteSpace($sharedRaw) + $packPresent = -not [string]::IsNullOrWhiteSpace($packRaw) + if ($sharedPresent -and $packPresent) { + [void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $sharedRaw -SourceName $names.SharedEnv) + [void](ConvertFrom-RepoUtilsSecretsPackJson -Raw $packRaw -SourceName $names.PackEnv) + Write-Log -Level 'INFO' -Message "Vault mode: pack env vars '$($names.SharedEnv)' and '$($names.PackEnv)' already set; skipping Vault fetch." + return + } + $connectionName = Get-RepoUtilsVaultConnectionSecretName -Settings $Settings $raw = [Environment]::GetEnvironmentVariable($connectionName) if ([string]::IsNullOrWhiteSpace($raw)) { @@ -367,9 +311,9 @@ function Initialize-RepoUtilsVaultSecrets { $connection = ConvertFrom-VaultConnectionSecret -Raw $raw $scope = Get-RepoUtilsVaultScope -Settings $Settings - $secretNames = @(Get-EnabledPluginSecretNames -Plugins $Plugins) + $orgPackApplication = Get-RepoUtilsVaultOrgPackApplicationName -Settings $Settings - Write-Log -Level 'INFO' -Message "Vault mode: loading $($secretNames.Count) plugin secret(s) for $($scope.Organization)/$($scope.Application)" + Write-Log -Level 'INFO' -Message "Vault mode: loading RepoUtilsSecrets packs for $($scope.Organization)/$orgPackApplication and $($scope.Organization)/$($scope.Application)" $mode = 'Rest' try { @@ -388,18 +332,34 @@ function Initialize-RepoUtilsVaultSecrets { $mode = 'Rest' } - foreach ($secretName in $secretNames) { - $value = Resolve-RepoUtilsVaultSecretValue ` + if (-not $sharedPresent) { + $sharedValue = Resolve-RepoUtilsVaultSecretValue ` -OrganizationName $scope.Organization ` - -ApplicationName $scope.Application ` - -SecretName $secretName ` + -ApplicationName $orgPackApplication ` + -SecretName $names.SharedEnv ` -Connection $connection ` -Mode $mode - if ([string]::IsNullOrWhiteSpace($value)) { - throw "Vault secret '$secretName' was not found for $($scope.Organization)/$($scope.Application) (or Shared) or has no current version." + if ([string]::IsNullOrWhiteSpace($sharedValue)) { + $sharedValue = '{}' + Write-Log -Level 'INFO' -Message "Vault secret '$($names.SharedEnv)' was not found for $($scope.Organization)/$orgPackApplication; using empty org-pack." } - [Environment]::SetEnvironmentVariable($secretName, $value, 'Process') + [Environment]::SetEnvironmentVariable($names.SharedEnv, $sharedValue, 'Process') + } + + if (-not $packPresent) { + $packValue = Resolve-RepoUtilsVaultSecretValue ` + -OrganizationName $scope.Organization ` + -ApplicationName $scope.Application ` + -SecretName $names.PackEnv ` + -Connection $connection ` + -Mode $mode + if ([string]::IsNullOrWhiteSpace($packValue)) { + $packValue = '{}' + Write-Log -Level 'INFO' -Message "Vault secret '$($names.PackEnv)' was not found for $($scope.Organization)/$($scope.Application); using empty slug-pack." + } + + [Environment]::SetEnvironmentVariable($names.PackEnv, $packValue, 'Process') } } @@ -408,6 +368,6 @@ Export-ModuleMember -Function @( 'ConvertFrom-VaultConnectionSecret', 'Get-RepoUtilsVaultScope', 'Get-RepoUtilsVaultConnectionSecretName', - 'Get-EnabledPluginSecretNames', + 'Get-RepoUtilsVaultOrgPackApplicationName', 'Initialize-RepoUtilsVaultSecrets' ) diff --git a/utils/plugins/Desktop/DesktopPackSupport.psm1 b/utils/plugins/Desktop/DesktopPackSupport.psm1 index c96f30b..490cebb 100644 --- a/utils/plugins/Desktop/DesktopPackSupport.psm1 +++ b/utils/plugins/Desktop/DesktopPackSupport.psm1 @@ -55,6 +55,37 @@ function Get-DesktopInstallFolderName { return $name } +function Get-WixArchitectureFromRuntimeIdentifier { + param( + [Parameter(Mandatory = $false)] + [string]$RuntimeIdentifier = '' + ) + + $rid = [string]$RuntimeIdentifier + if ($rid -match '(?i)arm64') { + return 'arm64' + } + + if ($rid -match '(?i)(^|-)x86($|-)') { + return 'x86' + } + + return 'x64' +} + +function Get-WixPerMachineProgramFilesFolderId { + param( + [Parameter(Mandatory = $false)] + [string]$Architecture = 'x64' + ) + + if ($Architecture -eq 'x86') { + return 'ProgramFilesFolder' + } + + return 'ProgramFiles64Folder' +} + function Get-MsiProductVersion { param( [Parameter(Mandatory = $true)] @@ -248,6 +279,9 @@ function New-WixPackageXml { [Parameter(Mandatory = $false)] [string]$InstallFolderName, + [Parameter(Mandatory = $false)] + [string]$Architecture = 'x64', + [Parameter(Mandatory = $false)] [string]$IconPath ) @@ -297,7 +331,12 @@ function New-WixPackageXml { -InstallFolderName $InstallFolderName $stdLocal = $xml.CreateElement('StandardDirectory', $ns) - $rootFolderId = if ($scope -eq 'perMachine') { 'ProgramFiles6432Folder' } else { 'LocalAppDataFolder' } + $rootFolderId = if ($scope -eq 'perMachine') { + Get-WixPerMachineProgramFilesFolderId -Architecture $Architecture + } + else { + 'LocalAppDataFolder' + } $null = $stdLocal.SetAttribute('Id', $rootFolderId) $null = $package.AppendChild($stdLocal) @@ -890,7 +929,10 @@ function New-WixBundleXml { [string]$InstallScope = 'perMachine', [Parameter(Mandatory = $false)] - [string]$InstallFolderName + [string]$InstallFolderName, + + [Parameter(Mandatory = $false)] + [string]$Architecture = 'x64' ) $escapedName = [System.Security.SecurityElement]::Escape($AppName) @@ -923,7 +965,7 @@ function New-WixBundleXml { } # Type=formatted so WixStdBA expands well-known folders in the InstallFolder edit box. - # Type=string shows the raw token, e.g. [ProgramFiles6432Folder]MaksIT\Cluster Console. + # Type=string shows the raw token, e.g. [ProgramFiles64Folder]MaksIT\Cluster Console. # Burn CSIDL folders already end with a backslash, so do not insert another one. # Layout is {ProgramFiles|LocalAppData}\{Manufacturer}\{product} — product folder is the # internal name (appName with manufacturer prefix stripped, or installFolderName). @@ -931,7 +973,7 @@ function New-WixBundleXml { '[LocalAppDataFolder]' } else { - '[ProgramFiles6432Folder]' + '[' + (Get-WixPerMachineProgramFilesFolderId -Architecture $Architecture) + ']' } $folderPath = if ([string]::IsNullOrWhiteSpace($Manufacturer)) { @@ -968,6 +1010,8 @@ Export-ModuleMember -Function ` ConvertTo-WixIdentifier, ` Get-MsiProductVersion, ` Get-DesktopInstallFolderName, ` + Get-WixArchitectureFromRuntimeIdentifier, ` + Get-WixPerMachineProgramFilesFolderId, ` Get-PluginPropertyValue, ` Resolve-DesktopPublishDirectory, ` Resolve-DesktopExecutablePath, ` diff --git a/utils/plugins/Desktop/WindowsInstaller.psm1 b/utils/plugins/Desktop/WindowsInstaller.psm1 index 9c1bd34..ce27788 100644 --- a/utils/plugins/Desktop/WindowsInstaller.psm1 +++ b/utils/plugins/Desktop/WindowsInstaller.psm1 @@ -9,6 +9,8 @@ Harvests a win-* (or sole) DotNetPublish folder into a WiX MSI, then wraps it in a Burn bootstrapper .exe. The .exe is the GitHub asset; the MSI/WXS stay in a staging folder and are not added to the portable zip. + `wix build -arch` follows `runtimeIdentifier` (default win-x64 → x64) so + per-machine installs go to `C:\Program Files`, not Program Files (x86). Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the OSMF EULA (`wix eula accept wix7` or `-acceptEula wix7`) and `wix extension add -g WixToolset.BootstrapperApplications.wixext`. @@ -102,6 +104,8 @@ function Invoke-Plugin { $upgradeCode = [guid]$upgradeCodeRaw $manufacturer = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'manufacturer' -Default 'MaksIT') $runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'win-x64') + $wixArch = Get-WixArchitectureFromRuntimeIdentifier -RuntimeIdentifier $runtimeIdentifier + $archArgs = @('-arch', $wixArch) $installScope = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installScope' -Default 'perMachine') $installFolderName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installFolderName') $executableName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'executableName') @@ -209,7 +213,7 @@ function Invoke-Plugin { $msiPath = Join-Path $stageDir ($safeName + '-' + $version + '.msi') $bundleWxsPath = Join-Path $stageDir ($safeName + '-' + $version + '-bundle.wxs') - Write-Log -Level "STEP" -Message "Generating WiX source for '$appName' from $publishDirectory" + Write-Log -Level "STEP" -Message "Generating WiX source for '$appName' from $publishDirectory ($wixArch)" $xml = New-WixPackageXml ` -AppName $appName ` -Manufacturer $manufacturer ` @@ -219,6 +223,7 @@ function Invoke-Plugin { -ExecutablePath $executablePath ` -InstallScope $installScope ` -InstallFolderName $installFolderName ` + -Architecture $wixArch ` -IconPath $iconPath $xml.Save($wxsPath) @@ -238,9 +243,9 @@ function Invoke-Plugin { $eulaArgs = @(Get-WixAcceptEulaArguments -VersionText $wixVersion) $bundleExt = Get-WixBundleExtensionName -VersionText $wixVersion - Write-Log -Level "STEP" -Message "Building MSI with WiX..." + Write-Log -Level "STEP" -Message "Building MSI with WiX ($wixArch)..." try { - Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + @($wxsPath, '-o', $msiPath)) | Out-Null + Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($wxsPath, '-o', $msiPath)) | Out-Null } catch { if (Test-WixMissingException -ErrorRecord $_) { @@ -265,16 +270,17 @@ function Invoke-Plugin { -LogoSidePath $logoSidePath ` -ThemePath $themePath ` -InstallScope $installScope ` - -InstallFolderName $installFolderName + -InstallFolderName $installFolderName ` + -Architecture $wixArch [System.IO.File]::WriteAllText($bundleWxsPath, $bundleXml, [System.Text.UTF8Encoding]::new($false)) if (Test-Path -LiteralPath $exePath -PathType Leaf) { Remove-Item -LiteralPath $exePath -Force } - Write-Log -Level "STEP" -Message "Building Windows installer exe..." + Write-Log -Level "STEP" -Message "Building Windows installer exe ($wixArch)..." try { - Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + @($bundleWxsPath, '-ext', $bundleExt, '-o', $exePath)) | Out-Null + Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-o', $exePath)) | Out-Null } catch { if (Test-WixMissingException -ErrorRecord $_) { diff --git a/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 b/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 deleted file mode 100644 index bbc7459..0000000 --- a/utils/plugins/DotNet/DotNetCleanupArtifacts.psm1 +++ /dev/null @@ -1,122 +0,0 @@ -#requires -Version 7.0 -#requires -PSEdition Core - -<# -.SYNOPSIS - .NET artifact cleanup plugin — remove NuGet build outputs after release. - -.DESCRIPTION - Removes files from the configured artifacts directory using glob patterns. - Defaults target NuGet outputs (*.nupkg, *.snupkg). Typically placed at the - end of the Release stage after DotNetCreateArchive or publish plugins. -#> - -if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) { - $srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1" - if (Test-Path $pluginSupportModulePath -PathType Leaf) { - Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop - } -} - -function Get-CleanupPatternsInternal { - param( - [Parameter(Mandatory = $false)] - $ConfiguredPatterns - ) - - if ($null -eq $ConfiguredPatterns) { - return @('*.nupkg', '*.snupkg') - } - - if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { - return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) - } - - if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { - return @('*.nupkg', '*.snupkg') - } - - return @([string]$ConfiguredPatterns) -} - -function Get-ExcludePatternsInternal { - param( - [Parameter(Mandatory = $false)] - $ConfiguredPatterns - ) - - if ($null -eq $ConfiguredPatterns) { - return @() - } - - if ($ConfiguredPatterns -is [System.Collections.IEnumerable] -and -not ($ConfiguredPatterns -is [string])) { - return @($ConfiguredPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) - } - - if ([string]::IsNullOrWhiteSpace([string]$ConfiguredPatterns)) { - return @() - } - - return @([string]$ConfiguredPatterns) -} - -function Invoke-Plugin { - param( - [Parameter(Mandatory = $true)] - $Settings - ) - - Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log" - - $pluginSettings = $Settings - $sharedSettings = $Settings.context - $artifactsDirectory = $sharedSettings.artifactsDirectory - $patterns = Get-CleanupPatternsInternal -ConfiguredPatterns $pluginSettings.includePatterns - $excludePatterns = Get-ExcludePatternsInternal -ConfiguredPatterns $pluginSettings.excludePatterns - - if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) { - throw "DotNetCleanupArtifacts plugin requires an artifacts directory in the shared context." - } - - if (-not (Test-Path $artifactsDirectory -PathType Container)) { - Write-Log -Level "WARN" -Message " Artifacts directory not found: $artifactsDirectory" - return - } - - Write-Log -Level "STEP" -Message "Cleaning generated artifacts..." - - $itemsToRemove = @() - foreach ($pattern in $patterns) { - $matchedItems = @( - Get-ChildItem -Path $artifactsDirectory -Force -ErrorAction SilentlyContinue | - Where-Object { $_.Name -like $pattern } - ) - - if ($excludePatterns.Count -gt 0) { - $matchedItems = @( - $matchedItems | - Where-Object { - $item = $_ - -not ($excludePatterns | Where-Object { $item.Name -like $_ } | Select-Object -First 1) - } - ) - } - - $itemsToRemove += @($matchedItems) - } - - $itemsToRemove = @($itemsToRemove | Sort-Object FullName -Unique) - - if ($itemsToRemove.Count -eq 0) { - Write-Log -Level "INFO" -Message " No artifacts matched cleanup rules." - return - } - - foreach ($item in $itemsToRemove) { - Remove-Item -Path $item.FullName -Recurse -Force -ErrorAction SilentlyContinue - Write-Log -Level "OK" -Message " Removed: $($item.Name)" - } -} - -Export-ModuleMember -Function Invoke-Plugin diff --git a/utils/plugins/DotNet/DotNetNuGet.psm1 b/utils/plugins/DotNet/DotNetNuGet.psm1 index ef08a81..514244a 100644 --- a/utils/plugins/DotNet/DotNetNuGet.psm1 +++ b/utils/plugins/DotNet/DotNetNuGet.psm1 @@ -29,10 +29,10 @@ function Invoke-Plugin { $pluginSettings = $Settings $sharedSettings = $Settings.context - $nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' $packageFile = $sharedSettings.packageFile $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings + $nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' -PluginDisplayName 'DotNetNuGet' -Required Assert-Command dotnet @@ -52,13 +52,9 @@ function Invoke-Plugin { return } - if ([string]::IsNullOrWhiteSpace($nugetSecret)) { - throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)." - } - - $nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret + $nugetKey = Get-RepoUtilsSecretSlot -Name $nugetSecret -Settings $sharedSettings if ([string]::IsNullOrWhiteSpace($nugetKey)) { - throw "NuGet API key is not set. Set environment variable '$nugetSecret'." + throw "NuGet API key is not set. Set RepoUtilsSecrets slot '$nugetSecret' (nugetSecret)." } $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) { diff --git a/utils/plugins/Npm/NpmPublish.psm1 b/utils/plugins/Npm/NpmPublish.psm1 index 7357ffe..9c25872 100644 --- a/utils/plugins/Npm/NpmPublish.psm1 +++ b/utils/plugins/Npm/NpmPublish.psm1 @@ -6,8 +6,7 @@ Publishes npm workspace packages to the npm registry. .DESCRIPTION - 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). + Publishes packages in configured order using RepoUtilsSecrets slot Npm. Uses a temporary .npmrc in the workspace root. #> @@ -85,6 +84,8 @@ function Invoke-Plugin { throw "NpmPublish plugin requires non-empty 'publishOrder' (workspace package names)." } + $npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' -PluginDisplayName 'NpmPublish' -Required + Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force $useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot if (-not $useWorkspaces -and $publishOrder.Count -gt 1) { @@ -99,14 +100,9 @@ function Invoke-Plugin { 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 + $npmToken = Get-RepoUtilsSecretSlot -Name $npmSecret -Settings $shared if ([string]::IsNullOrWhiteSpace($npmToken)) { - throw "npm API key is not set. Set environment variable '$npmSecret'." + throw "npm API key is not set. Set RepoUtilsSecrets slot '$npmSecret' (npmSecret)." } $registryHost = ([uri]$registry).Host diff --git a/utils/plugins/Platform/GitHub.psm1 b/utils/plugins/Platform/GitHub.psm1 index 5528635..89be3fc 100644 --- a/utils/plugins/Platform/GitHub.psm1 +++ b/utils/plugins/Platform/GitHub.psm1 @@ -101,7 +101,6 @@ function Invoke-Plugin { $pluginSettings = $Settings $sharedSettings = $Settings.context - $githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' $configuredRepository = $pluginSettings.repository $releaseNotesFileSetting = $pluginSettings.releaseNotesFile $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern @@ -112,6 +111,7 @@ function Invoke-Plugin { $releaseAssetPaths = @() $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings + $githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' -PluginDisplayName 'GitHub' -Required if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json." @@ -138,13 +138,9 @@ function Invoke-Plugin { 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 + $ghToken = Get-RepoUtilsSecretSlot -Name $githubSecret -Settings $sharedSettings if ([string]::IsNullOrWhiteSpace($ghToken)) { - throw "GitHub token is not set. Set environment variable '$githubSecret'." + throw "GitHub token is not set. Set RepoUtilsSecrets slot '$githubSecret' (githubSecret)." } if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { @@ -235,7 +231,7 @@ function Invoke-Plugin { $authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } } - throw "GitHub CLI authentication failed for repository '$repo'. Ensure secret '$githubSecret' is valid and has access to this repository." + throw "GitHub CLI authentication failed for repository '$repo'. Ensure RepoUtilsSecrets slot '$githubSecret' is valid and has access to this repository." } Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)"