maksit-lto-backup/utils/modules/Engine/PluginSupport.psm1
2026-09-06 15:33:46 +02:00

1218 lines
37 KiB
PowerShell

#requires -Version 7.0
#requires -PSEdition Core
function Get-RepoUtilsSrcDirectory {
return (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent)
}
function Get-RepoUtilsModulesDirectory {
return Split-Path $PSScriptRoot -Parent
}
if (-not (Get-Command Write-Log -ErrorAction SilentlyContinue)) {
$loggingModulePath = Join-Path (Get-RepoUtilsModulesDirectory) "Logging.psm1"
if (Test-Path $loggingModulePath -PathType Leaf) {
Import-Module $loggingModulePath -Force
}
}
function Test-IsEngineRuntimeModuleName {
param(
[Parameter(Mandatory = $true)]
[string]$ModuleName
)
# Host engine runtime under modules/ (and optional modules/Extensions/) — never dual-homed under plugins/.
$engineNames = [System.Collections.Generic.HashSet[string]]::new(
[string[]]@(
'ChangelogSupport',
'ExternalCommandSupport',
'GitTools',
'Logging',
'ScriptConfig',
'TestRunner',
'EngineContext',
'PluginSupport',
'VaultSupport',
'ReleaseSupport',
'TestSupport'
),
[System.StringComparer]::OrdinalIgnoreCase
)
return $engineNames.Contains($ModuleName)
}
function Get-PluginDependencyGroupDirectories {
param(
[Parameter(Mandatory = $true)]
[string]$PluginsRoot
)
if (-not (Test-Path -LiteralPath $PluginsRoot -PathType Container)) {
return @()
}
# Prefer Shared (helpers), then stock host groups; any other plugins/{Group}/ is discovered.
$preferred = @('Shared', 'Platform', 'DotNet', 'Npm', 'Desktop')
$dirs = [System.Collections.Generic.List[string]]::new()
foreach ($name in $preferred) {
$path = Join-Path $PluginsRoot $name
if (Test-Path -LiteralPath $path -PathType Container) {
$dirs.Add($path)
}
}
Get-ChildItem -LiteralPath $PluginsRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -notin $preferred } |
Sort-Object Name |
ForEach-Object { $dirs.Add($_.FullName) }
return @($dirs)
}
function Import-PluginDependency {
param(
[Parameter(Mandatory = $true)]
[string]$ModuleName,
[Parameter(Mandatory = $true)]
[string]$RequiredCommand
)
if (Get-Command $RequiredCommand -ErrorAction SilentlyContinue) {
return
}
$modulesDir = Get-RepoUtilsModulesDirectory
$engineModuleDir = $PSScriptRoot
$srcDir = Get-RepoUtilsSrcDirectory
$pluginsRoot = Join-Path $srcDir 'plugins'
$candidatePaths = [System.Collections.Generic.List[string]]::new()
if (Test-IsEngineRuntimeModuleName -ModuleName $ModuleName) {
# Engine runtime: modules/ only (no plugins/ fallback). Optional Extensions/ for layered hosts.
$candidatePaths.Add((Join-Path $modulesDir "$ModuleName.psm1"))
$candidatePaths.Add((Join-Path $engineModuleDir "$ModuleName.psm1"))
$extensionsDir = Join-Path $modulesDir 'Extensions'
$candidatePaths.Add((Join-Path $extensionsDir "$ModuleName.psm1"))
}
else {
# Plugin helpers: plugins/{Group}/ only (no modules/ legacy shadow). Groups are discovered.
foreach ($groupDir in Get-PluginDependencyGroupDirectories -PluginsRoot $pluginsRoot) {
$candidatePaths.Add((Join-Path $groupDir "$ModuleName.psm1"))
}
}
foreach ($modulePath in $candidatePaths) {
if (Test-Path -LiteralPath $modulePath -PathType Leaf) {
Import-Module $modulePath -Force -Global -ErrorAction Stop
break
}
}
if (-not (Get-Command $RequiredCommand -ErrorAction SilentlyContinue)) {
throw "Required command '$RequiredCommand' is still unavailable after importing module '$ModuleName'."
}
}
function Get-ConfiguredPlugins {
param(
[Parameter(Mandatory = $true)]
[psobject]$Settings
)
if (-not $Settings.PSObject.Properties['plugins'] -or $null -eq $Settings.plugins) {
return @()
}
if ($Settings.plugins -is [System.Collections.IEnumerable] -and -not ($Settings.plugins -is [string])) {
return @($Settings.plugins)
}
return @($Settings.plugins)
}
function Get-PluginStageLabel {
param(
[Parameter(Mandatory = $true)]
$Plugin
)
if (-not $Plugin.PSObject.Properties['stageLabel'] -or [string]::IsNullOrWhiteSpace([string]$Plugin.stageLabel)) {
return 'release'
}
return [string]$Plugin.stageLabel
}
function Get-PluginBranches {
param(
[Parameter(Mandatory = $true)]
$Plugin
)
if (-not $Plugin.PSObject.Properties['branches'] -or $null -eq $Plugin.branches) {
return @()
}
if ($Plugin.branches -is [System.Collections.IEnumerable] -and -not ($Plugin.branches -is [string])) {
return @($Plugin.branches | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
if ([string]::IsNullOrWhiteSpace([string]$Plugin.branches)) {
return @()
}
return @([string]$Plugin.branches)
}
function Test-PluginAllowedOnBranch {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[string]$CurrentBranch
)
$allowedBranches = Get-PluginBranches -Plugin $Plugin
if ($allowedBranches.Count -eq 0) {
return $true
}
if ($allowedBranches -contains '*') {
return $true
}
return $allowedBranches -contains $CurrentBranch
}
function Get-PluginMetadataObject {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[string]$EngineDirectory
)
$modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
if (-not (Test-Path $modulePath -PathType Leaf)) {
return $null
}
try {
$moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop
$metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue
if (-not $metadataCommand) {
return $null
}
return & $metadataCommand
}
catch {
return $null
}
}
function Test-PluginCompatible {
<#
.SYNOPSIS
Applies an optional compatibility policy supplied by an extension.
#>
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[string]$EngineDirectory,
[Parameter(Mandatory = $false)]
[bool]$WriteLogs = $true
)
$extensionTest = Get-Command Test-ExtensionPluginCompatibility -ErrorAction SilentlyContinue
if ($extensionTest) {
return & $extensionTest @PSBoundParameters
}
return $true
}
function Test-PluginMutatesRemote {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $false)]
[string]$EngineDirectory
)
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace([string]$Plugin.name)) {
return $false
}
if ([string]::IsNullOrWhiteSpace($EngineDirectory)) {
if ($Plugin.PSObject.Properties.Name -contains 'context' -and $null -ne $Plugin.context -and $Plugin.context.scriptDir) {
$EngineDirectory = [string]$Plugin.context.scriptDir
}
elseif ($Plugin.PSObject.Properties.Name -contains 'scriptDir' -and -not [string]::IsNullOrWhiteSpace([string]$Plugin.scriptDir)) {
$EngineDirectory = [string]$Plugin.scriptDir
}
}
if ([string]::IsNullOrWhiteSpace($EngineDirectory)) {
return $false
}
$modulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
if (-not (Test-Path $modulePath -PathType Leaf)) {
return $false
}
try {
$moduleInfo = Import-Module $modulePath -Force -PassThru -ErrorAction Stop
$metadataCommand = Get-Command -Name 'Get-PluginMetadata' -Module $moduleInfo.Name -ErrorAction SilentlyContinue
if (-not $metadataCommand) {
return $false
}
$metadata = & $metadataCommand
if ($null -eq $metadata) {
return $false
}
if ($metadata.PSObject.Properties.Name -contains 'mutatesRemote') {
return [bool]$metadata.mutatesRemote
}
}
catch {
return $false
}
return $false
}
function Get-RepoUtilsEnvironmentVariable {
<#
.SYNOPSIS
Reads a named environment variable from Process, then Windows User, then Machine.
.DESCRIPTION
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
)
if ([string]::IsNullOrWhiteSpace($Name)) {
throw "Environment variable name is required."
}
$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
}
foreach ($target in @('User', 'Machine')) {
try {
$value = [Environment]::GetEnvironmentVariable($Name, $target)
}
catch {
continue
}
if (-not [string]::IsNullOrWhiteSpace($value)) {
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`": `"<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 {
<#
.SYNOPSIS
Loads container-registry username/password from the merged RepoUtilsSecrets pack.
.DESCRIPTION
Reads a nested catalog slot (named by -Slot, typically ContainerRegistry) after
org-pack + slug-pack merge. -Key (PascalCase, ordinal match) selects an entry.
.PARAMETER Key
PascalCase catalog key (e.g. Harbor). Required.
.PARAMETER Slot
PascalCase pack subkey that holds the catalog object (from containerRegistrySecret).
.PARAMETER SharedSettings
Engine shared context (must declare repoUtilsSecretsShared / repoUtilsSecrets).
.OUTPUTS
Hashtable with User and Password keys (decoded credential material).
#>
param(
[Parameter(Mandatory = $true)]
[string]$Key,
[Parameter(Mandatory = $true)]
[string]$Slot,
[Parameter(Mandatory = $false)]
[psobject]$SharedSettings
)
if ([string]::IsNullOrWhiteSpace($Slot) -or -not (Test-ContainerRegistryCatalogKey -Key $Slot)) {
throw "containerRegistrySecret '$Slot' must be PascalCase (e.g. ContainerRegistry)."
}
if ([string]::IsNullOrWhiteSpace($Key)) {
throw "Pass -Key (PascalCase, e.g. Harbor) to select an entry in pack slot '$Slot'."
}
if (-not (Test-ContainerRegistryCatalogKey -Key $Key)) {
throw "containerRegistryKey '$Key' must be PascalCase (e.g. Harbor, InCluster)."
}
$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 {
param(
[Parameter(Mandatory = $true)]
$SharedSettings
)
if ($SharedSettings.PSObject.Properties.Name -contains 'engineScriptDir' -and -not [string]::IsNullOrWhiteSpace([string]$SharedSettings.engineScriptDir)) {
return [string]$SharedSettings.engineScriptDir
}
return [string]$SharedSettings.scriptDir
}
function Test-PluginSkipsRemoteMutation {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings
)
$engineDirectory = Resolve-EngineDirectoryFromSharedSettings -SharedSettings $SharedSettings
if (-not (Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $engineDirectory)) {
return $false
}
return ($Plugin.PSObject.Properties.Name -contains 'dryRun' -and $null -ne $Plugin.dryRun -and [bool]$Plugin.dryRun)
}
function Test-IsPublishPlugin {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $false)]
[string]$EngineDirectory
)
return Test-PluginMutatesRemote -Plugin $Plugin -EngineDirectory $EngineDirectory
}
function Get-PluginSettingValue {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
foreach ($plugin in $Plugins) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
continue
}
if (-not $plugin.PSObject.Properties[$PropertyName]) {
continue
}
$value = $plugin.$PropertyName
if ($null -eq $value) {
continue
}
if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) {
continue
}
return $value
}
return $null
}
function Get-PluginPathListSetting {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$PropertyName,
[Parameter(Mandatory = $true)]
[string]$BasePath
)
$rawPaths = @()
$value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName
if ($null -eq $value) {
return @()
}
if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
$rawPaths += $value
}
else {
$rawPaths += $value
}
$resolvedPaths = @()
foreach ($path in $rawPaths) {
if ([string]::IsNullOrWhiteSpace([string]$path)) {
continue
}
$resolvedPaths += [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$path)))
}
return @($resolvedPaths)
}
function Get-PluginPathSetting {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$PropertyName,
[Parameter(Mandatory = $true)]
[string]$BasePath
)
$value = Get-PluginSettingValue -Plugins $Plugins -PropertyName $PropertyName
if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) {
return $null
}
return [System.IO.Path]::GetFullPath((Join-Path $BasePath ([string]$value)))
}
function Get-ArchiveNamePattern {
param(
[Parameter(Mandatory = $true)]
[object[]]$Plugins,
[Parameter(Mandatory = $true)]
[string]$CurrentBranch
)
foreach ($plugin in $Plugins) {
if ($null -eq $plugin -or [string]::IsNullOrWhiteSpace($plugin.name)) {
continue
}
if (-not $plugin.enabled) {
continue
}
if (-not (Test-PluginAllowedOnBranch -Plugin $plugin -CurrentBranch $CurrentBranch)) {
continue
}
if ($plugin.PSObject.Properties['zipNamePattern'] -and -not [string]::IsNullOrWhiteSpace([string]$plugin.zipNamePattern)) {
return [string]$plugin.zipNamePattern
}
}
return "release-{version}.zip"
}
function Resolve-PluginModulePath {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[string]$EngineDirectory
)
$srcDir = Split-Path (Split-Path $EngineDirectory -Parent) -Parent
$pluginsRoot = Join-Path $srcDir "plugins"
$pluginFileName = "{0}.psm1" -f $Plugin.name
$candidatePaths = [System.Collections.Generic.List[string]]::new()
$candidatePaths.Add((Join-Path (Join-Path $EngineDirectory "custom") $pluginFileName))
$preferredGroups = @('Platform', 'DotNet', 'Npm', 'Desktop')
$candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $preferredGroups[0]) $pluginFileName))
if (Get-Command Get-ExtensionPluginModulePaths -ErrorAction SilentlyContinue) {
foreach ($extensionPath in Get-ExtensionPluginModulePaths -PluginsRoot $pluginsRoot -PluginFileName $pluginFileName) {
$candidatePaths.Add($extensionPath)
}
}
foreach ($group in $preferredGroups[1..($preferredGroups.Count - 1)]) {
$candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) $pluginFileName))
}
$reservedPluginDirs = @($preferredGroups + @('Shared'))
if (Test-Path -LiteralPath $pluginsRoot -PathType Container) {
Get-ChildItem -LiteralPath $pluginsRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -notin $reservedPluginDirs } |
Sort-Object Name |
ForEach-Object {
$candidatePaths.Add((Join-Path $_.FullName $pluginFileName))
}
}
foreach ($candidatePath in $candidatePaths) {
if (Test-Path $candidatePath -PathType Leaf) {
return $candidatePath
}
}
return $candidatePaths[0]
}
function Test-PluginRunnable {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings,
[Parameter(Mandatory = $true)]
[string]$EngineDirectory,
[Parameter(Mandatory = $false)]
[bool]$WriteLogs = $true
)
if ($null -eq $Plugin -or [string]::IsNullOrWhiteSpace($Plugin.name)) {
if ($WriteLogs) {
Write-Log -Level "WARN" -Message "Skipping plugin entry with no name."
}
return $false
}
if (-not $Plugin.enabled) {
if ($WriteLogs) {
Write-Log -Level "WARN" -Message "Skipping plugin '$($Plugin.name)' (disabled)."
}
return $false
}
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
if (-not (Test-Path $pluginModulePath -PathType Leaf)) {
if ($WriteLogs) {
Write-Log -Level "ERROR" -Message "Plugin module not found: $pluginModulePath"
}
return $false
}
return $true
}
function New-PluginInvocationSettings {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings
)
$properties = @{}
foreach ($property in $Plugin.PSObject.Properties) {
$properties[$property.Name] = $property.Value
}
$properties['context'] = $SharedSettings
return [pscustomobject]$properties
}
function Invoke-ConfiguredPlugin {
param(
[Parameter(Mandatory = $true)]
$Plugin,
[Parameter(Mandatory = $true)]
[psobject]$SharedSettings,
[Parameter(Mandatory = $true)]
[string]$EngineDirectory
)
if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) {
if ($Plugin.enabled) {
return $false
}
return $true
}
$metadata = Get-PluginMetadataObject -Plugin $Plugin -EngineDirectory $EngineDirectory
if ($null -ne $metadata -and ($metadata.PSObject.Properties.Name -contains 'providesVersion') -and [bool]$metadata.providesVersion) {
$versionAlreadySet = $false
if (Get-Command Get-EngineState -ErrorAction SilentlyContinue) {
$existingVersion = Get-EngineState -Context $SharedSettings -Name 'version' -ErrorAction SilentlyContinue
$versionAlreadySet = -not [string]::IsNullOrWhiteSpace([string]$existingVersion)
}
elseif (($SharedSettings.PSObject.Properties.Name -contains 'version') -and -not [string]::IsNullOrWhiteSpace([string]$SharedSettings.version)) {
$versionAlreadySet = $true
}
if ($versionAlreadySet) {
Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (version already resolved during New-EngineContext)."
return $true
}
# Test engine (and other hosts) may not resolve version in New-EngineContext; run the plugin now.
}
if ((Test-IsPublishPlugin -Plugin $Plugin) -and ($SharedSettings.PSObject.Properties.Name -contains 'skipPublishPlugins') -and $SharedSettings.skipPublishPlugins) {
Write-Log -Level "INFO" -Message "Skipping plugin '$($Plugin.name)' (ReleasePublishGuard suppressed publish)."
return $true
}
if (-not (Test-PluginCompatible -Plugin $Plugin -EngineDirectory $EngineDirectory -WriteLogs:$true)) {
return $true
}
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..."
# Sink plugin success-stream output to the host so it cannot pollute this
# function's return value. Otherwise `return $false` after CLI stdout becomes
# @("helm-line…", $false), which is truthy under `if (-not $result)` and
# causes RELEASE COMPLETE / exit 0 after a failed plugin.
try {
$moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop
$invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop
$pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings
& $invokeCommand -Settings $pluginSettings | ForEach-Object { Write-Host $_ }
Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed."
return $true
}
catch {
Write-Log -Level "ERROR" -Message " Plugin '$($Plugin.name)' failed: $($_.Exception.Message)"
return $false
}
}
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