(feature): persist operator settings in appdata; sync repoutils secret packs

This commit is contained in:
Maksym Sadovnychyy 2026-09-03 08:04:09 +02:00
parent 7da30fd459
commit 0ee07f7246
20 changed files with 838 additions and 374 deletions

View File

@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased] ## [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 ## [0.6.1] - 2026-08-30
### Changed ### 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. - 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. See [README.md](README.md) for the full feature list.

View File

@ -61,7 +61,9 @@ Connect a context from the catalog, pick a navigator item, then use the table, d
## Configuration ## 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 | | Key | Role |
|-----|------| |-----|------|

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>0.6.1</Version> <Version>0.6.2</Version>
<Product>MaksIT.ClusterConsole</Product> <Product>MaksIT.ClusterConsole</Product>
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle> <AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
</PropertyGroup> </PropertyGroup>

View File

@ -5,20 +5,36 @@ using System.Text.Json.Nodes;
namespace MaksIT.ClusterConsole.Shared; namespace MaksIT.ClusterConsole.Shared;
public sealed class ConfigurationFileService { public sealed class ConfigurationFileService {
public const string ProductFolder = "Cluster Console";
public const string SeedFileName = "appsettings.json";
private static readonly JsonSerializerOptions SerializerOptions = new() { private static readonly JsonSerializerOptions SerializerOptions = new() {
WriteIndented = true, WriteIndented = true,
PropertyNamingPolicy = null PropertyNamingPolicy = null
}; };
private readonly string? _seedPath;
private Configuration _current; private Configuration _current;
public string FilePath { get; } public string FilePath { get; }
public Configuration Current => _current; public Configuration Current => _current;
public ConfigurationFileService(string? configurationPath = null) { public ConfigurationFileService(string? configurationPath = null, string? seedPath = null) {
FilePath = configurationPath ?? Path.Combine(AppContext.BaseDirectory, "appsettings.json"); 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(); _current = LoadFromDisk();
CopySeedIfNeeded();
} }
public Configuration Reload() { public Configuration Reload() {
@ -29,24 +45,23 @@ public sealed class ConfigurationFileService {
public void Save(Configuration configuration) { public void Save(Configuration configuration) {
ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(configuration);
configuration.EnsureDefaults(); 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); root["Configuration"] = JsonSerializer.SerializeToNode(configuration, SerializerOptions);
File.WriteAllText(FilePath, root.ToJsonString(SerializerOptions)); File.WriteAllText(FilePath, root.ToJsonString(SerializerOptions));
_current = configuration; _current = configuration;
} }
private Configuration LoadFromDisk() { private Configuration LoadFromDisk() {
if (!File.Exists(FilePath)) var path = ResolveReadPath();
if (path is null)
return new Configuration(); 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)) if (!document.RootElement.TryGetProperty("Configuration", out var value))
return new Configuration(); return new Configuration();
@ -54,4 +69,31 @@ public sealed class ConfigurationFileService {
configuration.EnsureDefaults(); configuration.EnsureDefaults();
return configuration; 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;
}
} }

View File

@ -0,0 +1,17 @@
namespace MaksIT.ClusterConsole.Shared;
/// <summary>
/// User-writable operator settings under AppData. The product folder must match
/// the WiX <c>installFolderName</c> (or <c>Get-DesktopInstallFolderName</c> from
/// <c>appName</c> + <c>manufacturer</c>), e.g. <c>%AppData%/MaksIT/Cluster Console</c>.
/// Host logging stays in shipped <c>appsettings.json</c> next to the exe.
/// </summary>
public static class UserSettingsPath {
public static string Get(string product, string fileName = "settings.json") =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"MaksIT",
product,
fileName);
}

View File

@ -5,22 +5,5 @@
"Microsoft": "Warning", "Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information" "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": []
} }
} }

View File

@ -247,9 +247,76 @@ public class ConfigurationFileServiceTests {
} }
[Fact] [Fact]
public void Default_path_is_appsettings_beside_the_executable() { public void Default_path_is_appdata_under_maksit() {
var service = new ConfigurationFileService(); 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] [Fact]

View File

@ -22,6 +22,10 @@ public partial class App : Application {
.ConfigureAppConfiguration(builder => { .ConfigureAppConfiguration(builder => {
builder.SetBasePath(AppContext.BaseDirectory); builder.SetBasePath(AppContext.BaseDirectory);
builder.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true); builder.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
builder.AddJsonFile(
UserSettingsPath.Get(ConfigurationFileService.ProductFolder),
optional: true,
reloadOnChange: true);
}) })
.ConfigureServices((_, services) => { .ConfigureServices((_, services) => {
services.AddSingleton(_ => new ConfigurationFileService()); services.AddSingleton(_ => new ConfigurationFileService());

View File

@ -98,9 +98,9 @@
}, },
{ {
"name": "GitHub", "name": "GitHub",
"githubSecret": "GitClone",
"stageLabel": "release", "stageLabel": "release",
"enabled": true, "enabled": true,
"githubSecret": "GitHub",
"repository": "https://github.com/MAKS-IT-COM/maksit-cluster-console", "repository": "https://github.com/MAKS-IT-COM/maksit-cluster-console",
"releaseNotesFile": "..\\..\\..\\CHANGELOG.md", "releaseNotesFile": "..\\..\\..\\CHANGELOG.md",
"releaseTitlePattern": "Release {version}", "releaseTitlePattern": "Release {version}",
@ -119,5 +119,8 @@
"*.flatpak" "*.flatpak"
] ]
} }
] ],
"repoUtilsSecretsShared": "RepoUtilsSecretsShared",
"repoUtilsSecrets": "RepoUtilsSecrets",
"vaultRepoUtilsApplication": "Shared"
} }

View File

@ -46,5 +46,8 @@
"red": 0 "red": 0
} }
} }
] ],
"repoUtilsSecretsShared": "RepoUtilsSecretsShared",
"repoUtilsSecrets": "RepoUtilsSecrets",
"vaultRepoUtilsApplication": "Shared"
} }

View File

@ -294,129 +294,586 @@ function Test-PluginMutatesRemote {
return $false return $false
} }
function Get-SecretEnvironmentValue { function Get-RepoUtilsEnvironmentVariable {
<# <#
.SYNOPSIS .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 .DESCRIPTION
Plugins never store secret material in scriptSettings.json. Settings hold a Process wins (CICD sandbox inject, `$env:Name`, explicit session values). When the
logical name (e.g. "GitHub", "NuGet"); the process environment variable with current process was started before a User-level pack was set, User/Machine still
that same name must be set before the engine runs. apply so laptop releases do not require a new shell. An explicit process value —
including empty JSON `{}` — shadows User/Machine.
.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'
#> #>
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Name [string]$Name
) )
return [Environment]::GetEnvironmentVariable($Name) if ([string]::IsNullOrWhiteSpace($Name)) {
throw "Environment variable name is required."
} }
function Resolve-PluginSecretName { $processValue = [Environment]::GetEnvironmentVariable($Name, 'Process')
<# # Empty string is what SetEnvironmentVariable($null, Process) leaves behind;
.SYNOPSIS # treat it as unset so Windows User packs still apply. Explicit '{}' shadows User.
Resolves a logical secret name from a plugin's scriptSettings entry. if (-not [string]::IsNullOrWhiteSpace($processValue)) {
return $processValue
}
.DESCRIPTION foreach ($target in @('User', 'Machine')) {
Reads a string property such as githubSecret / nugetSecret / npmSecret / try {
containerRegistrySecret from the plugin settings object. Returns $null when $value = [Environment]::GetEnvironmentVariable($Name, $target)
the property is missing or blank. }
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)) { if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim() return $value
} }
} }
return $null 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`": `"<Base64>`", `"InCluster`": `"<Base64>`" }."
}
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 { function Get-RegistryCredentialsFromRuntime {
<# <#
.SYNOPSIS .SYNOPSIS
Loads container-registry username/password from a logical secret name. Loads container-registry username/password from the merged RepoUtilsSecrets pack.
.DESCRIPTION .DESCRIPTION
Looks up the environment variable named by SecretName. The value must be Reads a nested catalog slot (named by -Slot, typically ContainerRegistry) after
Base64(UTF8('username:password')). Used by registry login and image-pull org-pack + slug-pack merge. -Key (PascalCase, ordinal match) selects an entry.
secret creation — never pass the password itself as a parameter.
.PARAMETER SecretName .PARAMETER Key
Logical secret name (environment variable name), not a password or token. PascalCase catalog key (e.g. Harbor). Required.
.PARAMETER Slot
PascalCase pack subkey that holds the catalog object (from containerRegistrySecret).
.PARAMETER SharedSettings .PARAMETER SharedSettings
Optional engine shared context (reserved for callers that thread context). Engine shared context (must declare repoUtilsSecretsShared / repoUtilsSecrets).
.OUTPUTS .OUTPUTS
Hashtable with User and Password keys (decoded credential material). 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( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$SecretName, [string]$Key,
[Parameter(Mandatory = $true)]
[string]$Slot,
[Parameter(Mandatory = $false)] [Parameter(Mandatory = $false)]
[psobject]$SharedSettings [psobject]$SharedSettings
) )
$raw = Get-SecretEnvironmentValue -Name $SecretName if ([string]::IsNullOrWhiteSpace($Slot) -or -not (Test-ContainerRegistryCatalogKey -Key $Slot)) {
if ([string]::IsNullOrWhiteSpace($raw)) { throw "containerRegistrySecret '$Slot' must be PascalCase (e.g. ContainerRegistry)."
throw "Environment variable '$SecretName' is not set."
} }
try { if ([string]::IsNullOrWhiteSpace($Key)) {
$decoded = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($raw)) throw "Pass -Key (PascalCase, e.g. Harbor) to select an entry in pack slot '$Slot'."
}
catch {
throw "Failed to decode '$SecretName' as Base64 (expected base64('username:password')): $($_.Exception.Message)"
} }
$parts = $decoded -split ':', 2 if (-not (Test-ContainerRegistryCatalogKey -Key $Key)) {
if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) { throw "containerRegistryKey '$Key' must be PascalCase (e.g. Harbor, InCluster)."
throw "Decoded '$SecretName' must be in the form 'username:password'."
} }
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 { 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

View File

@ -213,6 +213,7 @@ function New-EngineContext {
skipPublishPlugins = $false skipPublishPlugins = $false
facts = [ordered]@{} facts = [ordered]@{}
} }
$context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings
$versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir $versionSource = Resolve-EngineContextVersion -Plugins $Plugins -Context $context -ScriptDir $ScriptDir
$version = [string](Get-EngineState -Context $context -Name 'version' -Required) $version = [string](Get-EngineState -Context $context -Name 'version' -Required)

View File

@ -38,6 +38,7 @@ function New-EngineContext {
utilsDir = $SrcDir utilsDir = $SrcDir
facts = [ordered]@{} facts = [ordered]@{}
} }
$context = Copy-RepoUtilsSecretsEnvNamesToContext -Context $context -Settings $Settings
$expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue $expandContext = Get-Command Expand-ExtensionEngineContext -ErrorAction SilentlyContinue
if ($expandContext) { if ($expandContext) {

View File

@ -86,89 +86,23 @@ function Get-RepoUtilsVaultConnectionSecretName {
return 'MAKSIT_VAULT' return 'MAKSIT_VAULT'
} }
function Add-RepoUtilsSecretNamesFromObject { function Get-RepoUtilsVaultOrgPackApplicationName {
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 {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
$Plugins $Settings
) )
$names = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) $application = $null
foreach ($plugin in @($Plugins)) { if ($Settings.PSObject.Properties.Name -contains 'vaultRepoUtilsApplication') {
if ($null -eq $plugin) { $application = [string]$Settings.vaultRepoUtilsApplication
continue
} }
if (($plugin.PSObject.Properties.Name -contains 'enabled') -and ($plugin.enabled -eq $false)) { $application = if ($null -eq $application) { '' } else { $application.Trim() }
continue 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)."
} }
Add-RepoUtilsSecretNamesFromObject -Object $plugin -Names $names return $application
}
return @($names)
} }
function Get-VaultNameFilterExpression { function Get-VaultNameFilterExpression {
@ -325,25 +259,23 @@ function Resolve-RepoUtilsVaultSecretValue {
[string]$Mode [string]$Mode
) )
foreach ($application in @($ApplicationName, 'Shared')) {
$match = Find-RepoUtilsVaultSecretMatch ` $match = Find-RepoUtilsVaultSecretMatch `
-OrganizationName $OrganizationName ` -OrganizationName $OrganizationName `
-ApplicationName $application ` -ApplicationName $ApplicationName `
-SecretName $SecretName ` -SecretName $SecretName `
-Connection $Connection ` -Connection $Connection `
-Mode $Mode -Mode $Mode
if ($null -eq $match) { if ($null -eq $match) {
continue return $null
} }
$value = Get-RepoUtilsVaultSecretValue -Match $match -Connection $Connection -Mode $Mode $value = Get-RepoUtilsVaultSecretValue -Match $match -Connection $Connection -Mode $Mode
if (-not [string]::IsNullOrWhiteSpace($value)) { if ([string]::IsNullOrWhiteSpace($value)) {
Write-Log -Level 'INFO' -Message "Resolved Vault secret '$SecretName' from $OrganizationName/$application" return $null
return $value
}
} }
return $null Write-Log -Level 'INFO' -Message "Resolved Vault secret '$SecretName' from $OrganizationName/$ApplicationName"
return $value
} }
function Initialize-RepoUtilsVaultSecrets { function Initialize-RepoUtilsVaultSecrets {
@ -359,6 +291,18 @@ function Initialize-RepoUtilsVaultSecrets {
return 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 $connectionName = Get-RepoUtilsVaultConnectionSecretName -Settings $Settings
$raw = [Environment]::GetEnvironmentVariable($connectionName) $raw = [Environment]::GetEnvironmentVariable($connectionName)
if ([string]::IsNullOrWhiteSpace($raw)) { if ([string]::IsNullOrWhiteSpace($raw)) {
@ -367,9 +311,9 @@ function Initialize-RepoUtilsVaultSecrets {
$connection = ConvertFrom-VaultConnectionSecret -Raw $raw $connection = ConvertFrom-VaultConnectionSecret -Raw $raw
$scope = Get-RepoUtilsVaultScope -Settings $Settings $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' $mode = 'Rest'
try { try {
@ -388,18 +332,34 @@ function Initialize-RepoUtilsVaultSecrets {
$mode = 'Rest' $mode = 'Rest'
} }
foreach ($secretName in $secretNames) { if (-not $sharedPresent) {
$value = Resolve-RepoUtilsVaultSecretValue ` $sharedValue = Resolve-RepoUtilsVaultSecretValue `
-OrganizationName $scope.Organization ` -OrganizationName $scope.Organization `
-ApplicationName $scope.Application ` -ApplicationName $orgPackApplication `
-SecretName $secretName ` -SecretName $names.SharedEnv `
-Connection $connection ` -Connection $connection `
-Mode $mode -Mode $mode
if ([string]::IsNullOrWhiteSpace($value)) { if ([string]::IsNullOrWhiteSpace($sharedValue)) {
throw "Vault secret '$secretName' was not found for $($scope.Organization)/$($scope.Application) (or Shared) or has no current version." $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', 'ConvertFrom-VaultConnectionSecret',
'Get-RepoUtilsVaultScope', 'Get-RepoUtilsVaultScope',
'Get-RepoUtilsVaultConnectionSecretName', 'Get-RepoUtilsVaultConnectionSecretName',
'Get-EnabledPluginSecretNames', 'Get-RepoUtilsVaultOrgPackApplicationName',
'Initialize-RepoUtilsVaultSecrets' 'Initialize-RepoUtilsVaultSecrets'
) )

View File

@ -55,6 +55,37 @@ function Get-DesktopInstallFolderName {
return $name 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 { function Get-MsiProductVersion {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -248,6 +279,9 @@ function New-WixPackageXml {
[Parameter(Mandatory = $false)] [Parameter(Mandatory = $false)]
[string]$InstallFolderName, [string]$InstallFolderName,
[Parameter(Mandatory = $false)]
[string]$Architecture = 'x64',
[Parameter(Mandatory = $false)] [Parameter(Mandatory = $false)]
[string]$IconPath [string]$IconPath
) )
@ -297,7 +331,12 @@ function New-WixPackageXml {
-InstallFolderName $InstallFolderName -InstallFolderName $InstallFolderName
$stdLocal = $xml.CreateElement('StandardDirectory', $ns) $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 = $stdLocal.SetAttribute('Id', $rootFolderId)
$null = $package.AppendChild($stdLocal) $null = $package.AppendChild($stdLocal)
@ -890,7 +929,10 @@ function New-WixBundleXml {
[string]$InstallScope = 'perMachine', [string]$InstallScope = 'perMachine',
[Parameter(Mandatory = $false)] [Parameter(Mandatory = $false)]
[string]$InstallFolderName [string]$InstallFolderName,
[Parameter(Mandatory = $false)]
[string]$Architecture = 'x64'
) )
$escapedName = [System.Security.SecurityElement]::Escape($AppName) $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=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. # Burn CSIDL folders already end with a backslash, so do not insert another one.
# Layout is {ProgramFiles|LocalAppData}\{Manufacturer}\{product} — product folder is the # Layout is {ProgramFiles|LocalAppData}\{Manufacturer}\{product} — product folder is the
# internal name (appName with manufacturer prefix stripped, or installFolderName). # internal name (appName with manufacturer prefix stripped, or installFolderName).
@ -931,7 +973,7 @@ function New-WixBundleXml {
'[LocalAppDataFolder]' '[LocalAppDataFolder]'
} }
else { else {
'[ProgramFiles6432Folder]' '[' + (Get-WixPerMachineProgramFilesFolderId -Architecture $Architecture) + ']'
} }
$folderPath = if ([string]::IsNullOrWhiteSpace($Manufacturer)) { $folderPath = if ([string]::IsNullOrWhiteSpace($Manufacturer)) {
@ -968,6 +1010,8 @@ Export-ModuleMember -Function `
ConvertTo-WixIdentifier, ` ConvertTo-WixIdentifier, `
Get-MsiProductVersion, ` Get-MsiProductVersion, `
Get-DesktopInstallFolderName, ` Get-DesktopInstallFolderName, `
Get-WixArchitectureFromRuntimeIdentifier, `
Get-WixPerMachineProgramFilesFolderId, `
Get-PluginPropertyValue, ` Get-PluginPropertyValue, `
Resolve-DesktopPublishDirectory, ` Resolve-DesktopPublishDirectory, `
Resolve-DesktopExecutablePath, ` Resolve-DesktopExecutablePath, `

View File

@ -9,6 +9,8 @@
Harvests a win-* (or sole) DotNetPublish folder into a WiX MSI, then wraps 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 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. 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 Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the
OSMF EULA (`wix eula accept wix7` or `-acceptEula wix7`) and OSMF EULA (`wix eula accept wix7` or `-acceptEula wix7`) and
`wix extension add -g WixToolset.BootstrapperApplications.wixext`. `wix extension add -g WixToolset.BootstrapperApplications.wixext`.
@ -102,6 +104,8 @@ function Invoke-Plugin {
$upgradeCode = [guid]$upgradeCodeRaw $upgradeCode = [guid]$upgradeCodeRaw
$manufacturer = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'manufacturer' -Default 'MaksIT') $manufacturer = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'manufacturer' -Default 'MaksIT')
$runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'win-x64') $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') $installScope = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installScope' -Default 'perMachine')
$installFolderName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installFolderName') $installFolderName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'installFolderName')
$executableName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'executableName') $executableName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'executableName')
@ -209,7 +213,7 @@ function Invoke-Plugin {
$msiPath = Join-Path $stageDir ($safeName + '-' + $version + '.msi') $msiPath = Join-Path $stageDir ($safeName + '-' + $version + '.msi')
$bundleWxsPath = Join-Path $stageDir ($safeName + '-' + $version + '-bundle.wxs') $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 ` $xml = New-WixPackageXml `
-AppName $appName ` -AppName $appName `
-Manufacturer $manufacturer ` -Manufacturer $manufacturer `
@ -219,6 +223,7 @@ function Invoke-Plugin {
-ExecutablePath $executablePath ` -ExecutablePath $executablePath `
-InstallScope $installScope ` -InstallScope $installScope `
-InstallFolderName $installFolderName ` -InstallFolderName $installFolderName `
-Architecture $wixArch `
-IconPath $iconPath -IconPath $iconPath
$xml.Save($wxsPath) $xml.Save($wxsPath)
@ -238,9 +243,9 @@ function Invoke-Plugin {
$eulaArgs = @(Get-WixAcceptEulaArguments -VersionText $wixVersion) $eulaArgs = @(Get-WixAcceptEulaArguments -VersionText $wixVersion)
$bundleExt = Get-WixBundleExtensionName -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 { 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 { catch {
if (Test-WixMissingException -ErrorRecord $_) { if (Test-WixMissingException -ErrorRecord $_) {
@ -265,16 +270,17 @@ function Invoke-Plugin {
-LogoSidePath $logoSidePath ` -LogoSidePath $logoSidePath `
-ThemePath $themePath ` -ThemePath $themePath `
-InstallScope $installScope ` -InstallScope $installScope `
-InstallFolderName $installFolderName -InstallFolderName $installFolderName `
-Architecture $wixArch
[System.IO.File]::WriteAllText($bundleWxsPath, $bundleXml, [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($bundleWxsPath, $bundleXml, [System.Text.UTF8Encoding]::new($false))
if (Test-Path -LiteralPath $exePath -PathType Leaf) { if (Test-Path -LiteralPath $exePath -PathType Leaf) {
Remove-Item -LiteralPath $exePath -Force 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 { 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 { catch {
if (Test-WixMissingException -ErrorRecord $_) { if (Test-WixMissingException -ErrorRecord $_) {

View File

@ -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

View File

@ -29,10 +29,10 @@ function Invoke-Plugin {
$pluginSettings = $Settings $pluginSettings = $Settings
$sharedSettings = $Settings.context $sharedSettings = $Settings.context
$nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret'
$packageFile = $sharedSettings.packageFile $packageFile = $sharedSettings.packageFile
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings
$nugetSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'nugetSecret' -PluginDisplayName 'DotNetNuGet' -Required
Assert-Command dotnet Assert-Command dotnet
@ -52,13 +52,9 @@ function Invoke-Plugin {
return return
} }
if ([string]::IsNullOrWhiteSpace($nugetSecret)) { $nugetKey = Get-RepoUtilsSecretSlot -Name $nugetSecret -Settings $sharedSettings
throw "DotNetNuGet plugin requires 'nugetSecret' in scriptSettings.json (logical secret name, e.g. NuGet)."
}
$nugetKey = Get-SecretEnvironmentValue -Name $nugetSecret
if ([string]::IsNullOrWhiteSpace($nugetKey)) { 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)) { $nugetSource = if ([string]::IsNullOrWhiteSpace($pluginSettings.source)) {

View File

@ -6,8 +6,7 @@
Publishes npm workspace packages to the npm registry. Publishes npm workspace packages to the npm registry.
.DESCRIPTION .DESCRIPTION
Publishes packages in configured order using npmSecret (logical secret name). Publishes packages in configured order using RepoUtilsSecrets slot Npm.
Pass the token via an environment variable named like the configured npm secret (e.g. Npm).
Uses a temporary .npmrc in the workspace root. 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)." 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 Import-Module (Join-Path $PSScriptRoot 'NpmPackageSupport.psm1') -Force
$useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot $useWorkspaces = Test-NpmWorkspacesConfigured -WorkspaceRoot $workspaceRoot
if (-not $useWorkspaces -and $publishOrder.Count -gt 1) { if (-not $useWorkspaces -and $publishOrder.Count -gt 1) {
@ -99,14 +100,9 @@ function Invoke-Plugin {
return return
} }
$npmSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'npmSecret' $npmToken = Get-RepoUtilsSecretSlot -Name $npmSecret -Settings $shared
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)) { 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 $registryHost = ([uri]$registry).Host

View File

@ -101,7 +101,6 @@ function Invoke-Plugin {
$pluginSettings = $Settings $pluginSettings = $Settings
$sharedSettings = $Settings.context $sharedSettings = $Settings.context
$githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret'
$configuredRepository = $pluginSettings.repository $configuredRepository = $pluginSettings.repository
$releaseNotesFileSetting = $pluginSettings.releaseNotesFile $releaseNotesFileSetting = $pluginSettings.releaseNotesFile
$releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern $releaseTitlePatternSetting = $pluginSettings.releaseTitlePattern
@ -112,6 +111,7 @@ function Invoke-Plugin {
$releaseAssetPaths = @() $releaseAssetPaths = @()
$dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings $dryRun = Test-PluginSkipsRemoteMutation -Plugin $pluginSettings -SharedSettings $sharedSettings
$githubSecret = Resolve-PluginSecretName -PluginSettings $pluginSettings -PropertyName 'githubSecret' -PluginDisplayName 'GitHub' -Required
if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) { if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json." throw "GitHub plugin requires 'releaseNotesFile' in scriptSettings.json."
@ -138,13 +138,9 @@ function Invoke-Plugin {
Assert-Command gh Assert-Command gh
if ([string]::IsNullOrWhiteSpace($githubSecret)) { $ghToken = Get-RepoUtilsSecretSlot -Name $githubSecret -Settings $sharedSettings
throw "GitHub plugin requires 'githubSecret' in scriptSettings.json (logical secret name, e.g. GitHub)."
}
$ghToken = Get-SecretEnvironmentValue -Name $githubSecret
if ([string]::IsNullOrWhiteSpace($ghToken)) { 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)) { if ([string]::IsNullOrWhiteSpace($releaseNotesFileSetting)) {
@ -235,7 +231,7 @@ function Invoke-Plugin {
$authStatus | ForEach-Object { Write-Log -Level "WARN" -Message " $_" } $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)" Write-Log -Level "OK" -Message " GitHub token validated for repository: $($authOutput | Select-Object -First 1)"