PwSh.Fw.GenericPackageEngine.psm1
|
#requires -version 7.0 $Script:ModuleRoot = $PSScriptRoot # Format marker : every pkgdef file must carry this exact value at the root # level. It identifies the file as a PwSh.Fw.GenericPackageEngine definition # and pins the specification version (see schemas/pkgdef.schema.json). $Script:PkgDefFormat = 'PwSh.Fw.GenericPackageEngine/v2' # ═══════════════════════════════════════════════════════════════════════════════ # PHASE 1 : RESOLVE # Resolve a product definition for one given target # ═══════════════════════════════════════════════════════════════════════════════ <# .SYNOPSIS Resolve a product pkgdef for one target. .DESCRIPTION Loads a pkgdef v2 definition (from a file or a hashtable) and resolves it for one target. The definition must carry the format marker 'pkgdef: PwSh.Fw.GenericPackageEngine/v2' (first key by convention) ; definitions without a marker, or with an unsupported format version, are rejected. - download : key-by-key merge of the global block and the target block (target values win) — the only merge of the schema ; - install / post : replacement (target block wins, no concatenation) ; - check : replacement (target block wins, no concatenation) ; - version : target value wins over the global value ; with -ResolveLatest, a 'latest' version is replaced by the concrete version found at the provider before returning (github/gitlab : latest release tag, webscrapper : highest (?<ver>) capture on the page, url : version-like token in the URL path, gitlab-artifact : the ref when it is a version). API-backed providers fail (Write-Fatal) when no version can be produced ; url / gitlab-artifact keep the literal token when nothing can be derived locally. - payload, upload : taken from the target when present. The target is selected either explicitly (-TargetId) or by platform match (-Os / -Distro / -Arch). Platform match rules : - os : exact match ; - arch: exact match, or target.arch is 'all', or no arch requested ; - distro: exact match (an empty requested distro only matches targets without a distro) ; - first match wins. .EXAMPLE $resolved = Resolve-GenericPackageDefinition -Path ./examples/firefox.yml -TargetId windows-x64 .EXAMPLE $resolved = Resolve-GenericPackageDefinition -Path ./examples/libreoffice.yml -Os linux -Distro debian -Arch x64 #> function Resolve-GenericPackageDefinition { [CmdletBinding()] [OutputType([hashtable])] Param ( # Path to the pkgdef file [Parameter(Mandatory = $false)][string]$Path, # Package definition (hashtable, e.g. loaded yaml) [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition, # Explicit target id (e.g. windows-x64) [Parameter(Mandatory = $false)][string]$TargetId, # Target OS: 'windows', 'linux', 'macos' [Parameter(Mandatory = $false)][ValidateSet('windows', 'linux', 'macos')][string]$Os, # Target distribution (linux) [Parameter(Mandatory = $false)][string]$Distro = '', # Target architecture [Parameter(Mandatory = $false)][ValidateSet('x86', 'x64', 'arm32', 'arm64', 'all')][string]$Arch = '', # Replace a 'latest' version with the provider's current version (network call for github/gitlab/webscrapper) [Parameter(Mandatory = $false)][switch]$ResolveLatest ) Begin { Write-EnterFunction } Process { if (-not $Path -and -not $Definition) { Write-Fatal "Either -Path or -Definition must be provided" } if ($Path -and $Definition) { Write-Fatal "Provide either -Path or -Definition, not both" } if ($TargetId -and $Os) { Write-Fatal "Provide either -TargetId or -Os, not both" } if (-not $TargetId -and -not $Os) { Write-Fatal "A target must be selected : -TargetId or -Os (plus -Distro/-Arch)" } # ─── Load raw definition ────────────────────────────────────────────── if ($Path) { if (-not (Test-FileExist $Path)) { Write-Fatal "File not found : $Path" } $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable } else { $raw = ConvertTo-PlainHashtable -InputObject $Definition } # Format marker : identity check before anything else if (-not $raw -or -not $raw.pkgdef) { Write-Fatal "Missing format marker : expected 'pkgdef: $Script:PkgDefFormat' as the first key (this file is not a PwSh.Fw.GenericPackageEngine definition)" } if ($raw.pkgdef -ne $Script:PkgDefFormat) { Write-Fatal "Unsupported pkgdef format : '$($raw.pkgdef)' (this engine supports '$Script:PkgDefFormat')" } if (-not $raw.name) { Write-Fatal "Invalid pkgdef : 'name' is missing" } # NB : @($raw.targets) wraps a missing key into @($null) — filter nulls $targets = @(@($raw.targets) | Where-Object { $null -ne $_ }) if ($targets.Count -eq 0) { Write-Fatal "No targets defined in pkgdef '$($raw.name)'" } # ─── Select target ──────────────────────────────────────────────────── if ($TargetId) { $target = @($targets | Where-Object { $_.id -eq $TargetId }) | Select-Object -First 1 if (-not $target) { Write-Fatal "Target '$TargetId' not found in pkgdef '$($raw.name)' (available : $(($targets | ForEach-Object { $_.id }) -join ', '))" } } else { $target = @($targets | Where-Object { $_.os -eq $Os.ToLower() ` -and ($_.arch -eq $Arch -or $_.arch -eq 'all' -or [string]::IsNullOrEmpty($Arch)) ` -and (([string]::IsNullOrEmpty($Distro) -and [string]::IsNullOrEmpty($_.distro)) -or ($_.distro -eq $Distro.ToLower())) }) | Select-Object -First 1 if (-not $target) { Write-Fatal "No target matching os=$Os distro=$Distro arch=$Arch in pkgdef '$($raw.name)'" } } # ─── Build resolved definition ──────────────────────────────────────── $resolved = @{ pkgdef = $raw.pkgdef name = $raw.name codename = $raw.codename ?? (($raw.name.ToLower() -replace '\s+', '-')) type = 'product' version = ($target.version ?? $raw.version) ?? 'latest' depends = @($raw.depends) tags = @($raw.tags) download = Merge-GenericPackageDownload -Global $raw.download -Override $target.download install = @($target.install ?? $raw.install) post = @($target.post ?? $raw.post) check = $target.check ?? $raw.check target = @{ id = $target.id os = $target.os distro = $target.distro ?? '' arch = $target.arch } } if ($target.payload) { $resolved.payload = $target.payload } if ($target.upload) { $resolved.upload = $target.upload } # ─── Optional : resolve 'latest' to the provider's current version ─── if ($ResolveLatest -and "$($resolved.version)" -ieq 'latest') { $mvSeed = Get-GenericPackageMustacheValues -pkgdef $resolved $concrete = Get-GenericPackageLatestVersion -download $resolved.download -mustacheValues $mvSeed if ($concrete) { Write-Verbose "Resolved 'latest' to '$concrete' (provider '$($resolved.download.provider)')" $resolved.version = $concrete } # $null : no external source of truth for this provider (url with # a literal 'latest', gitlab-artifact branch ref) — token stays } Write-Debug "Resolved '$($raw.name)' for target '$($target.id)' (os=$($target.os) distro=$($target.distro ?? '') arch=$($target.arch) version=$($resolved.version))" return $resolved } End { Write-LeaveFunction } } # ═══════════════════════════════════════════════════════════════════════════════ # EXPAND : one resolved file per target (CI matrix) # ═══════════════════════════════════════════════════════════════════════════════ <# .SYNOPSIS Expand a pkgdef into one resolved definition file per target. .DESCRIPTION Resolves every target of the product and writes one YAML file per target, named '<codename>-<target.id>.yml', into -OutputDir. Returns the list of written file paths. Useful to feed a CI matrix. .EXAMPLE $files = Expand-GenericPackageTargets -Path ./examples/firefox.yml -OutputDir ./resolved #> function Expand-GenericPackageTargets { [CmdletBinding()] [OutputType([array])] Param ( # Path to the pkgdef file [Parameter(Mandatory = $false)][string]$Path, # Package definition (hashtable) [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition, # Output directory for the resolved files [Parameter(Mandatory = $true)][string]$OutputDir, # Overwrite existing files [switch]$Force ) Begin { Write-EnterFunction } Process { if (-not $Path -and -not $Definition) { Write-Fatal "Either -Path or -Definition must be provided" } if ($Path -and $Definition) { Write-Fatal "Provide either -Path or -Definition, not both" } if ($Path) { if (-not (Test-FileExist $Path)) { Write-Fatal "File not found : $Path" } $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable } else { $raw = ConvertTo-PlainHashtable -InputObject $Definition } if (-not (Test-DirExist $OutputDir)) { $null = New-Item -Path $OutputDir -ItemType Directory -Force } $files = @() foreach ($target in @($raw.targets)) { $resolved = Resolve-GenericPackageDefinition -Definition $raw -TargetId $target.id $fileName = "$($resolved.codename)-$($target.id).yml" $filePath = Join-Path $OutputDir $fileName # NB : parenthesize the command call — without them PowerShell # binds '-and' as a parameter of Test-FileExist if ((Test-FileExist $filePath) -and (-not $Force)) { Write-Warning "File exists, skipped (use -Force to overwrite) : $filePath" continue } $resolved | ConvertTo-Yaml -Options WithIndentedSequences | Set-Content -Path $filePath -Encoding UTF8 Write-Verbose "Wrote $filePath" $files += $filePath } return $files } End { Write-LeaveFunction } } # ═══════════════════════════════════════════════════════════════════════════════ # VALIDATE : semantic checks on top of the JSON Schema # ═══════════════════════════════════════════════════════════════════════════════ <# .SYNOPSIS Semantic validation of a pkgdef. .DESCRIPTION Complements the static JSON Schema validation with semantic checks : - presence and version of the pkgdef format marker ; - uniqueness of target ids ; - validity of asset / signature-file regex patterns ; - provider coherence (owner/repo, url, artifact) ; - declared payload vs asset pattern extension mismatch (warning) ; - check expression structure (one key per node, and/or arity, not child, file-exists leaf fields) ; - check relying on {{ ASSET }} (warning : only available after download) ; - upload coherence (nexus requires repository). Returns a results object : [PSCustomObject]@{ Valid ; Errors ; Warnings }. .EXAMPLE $result = Test-GenericPackageDefinition -Path ./examples/firefox.yml if (-not $result.Valid) { $result.Errors } #> function Test-GenericPackageDefinition { [CmdletBinding()] [OutputType([PSCustomObject])] Param ( # Path to the pkgdef file [Parameter(Mandatory = $false)][string]$Path, # Package definition (hashtable) [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition ) Begin { Write-EnterFunction $errors = @() $warnings = @() } Process { if (-not $Path -and -not $Definition) { Write-Fatal "Either -Path or -Definition must be provided" } if ($Path -and $Definition) { Write-Fatal "Provide either -Path or -Definition, not both" } if ($Path) { if (-not (Test-FileExist $Path)) { return [PSCustomObject]@{ Valid = $false; Errors = @("File not found : $Path"); Warnings = @() } } $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable } else { $raw = ConvertTo-PlainHashtable -InputObject $Definition } # Format marker : identity check before anything else if (-not $raw -or -not $raw.pkgdef) { return [PSCustomObject]@{ Valid = $false; Errors = @("missing format marker : expected 'pkgdef: $Script:PkgDefFormat'"); Warnings = @() } } if ($raw.pkgdef -ne $Script:PkgDefFormat) { return [PSCustomObject]@{ Valid = $false; Errors = @("unsupported pkgdef format : '$($raw.pkgdef)'"); Warnings = @() } } if (-not $raw -or -not $raw.name) { return [PSCustomObject]@{ Valid = $false; Errors = @("'name' is missing") ; Warnings = @() } } # NB : @($raw.targets) wraps a missing key into @($null) — filter nulls $targets = @(@($raw.targets) | Where-Object { $null -ne $_ }) if ($targets.Count -eq 0) { $errors += "product requires at least one target" } # ─── target id uniqueness ───────────────────────────────────────────── $ids = @($targets | ForEach-Object { $_.id }) foreach ($dup in ($ids | Group-Object | Where-Object { $_.Count -gt 1 })) { $errors += "duplicate target id : $($dup.Name)" } # ─── per-target checks ──────────────────────────────────────────────── foreach ($target in $targets) { # merged download view (same rule as Resolve) $download = @{} if ($raw.download) { foreach ($k in $raw.download.Keys) { $download[$k] = $raw.download[$k] } } if ($target.download) { foreach ($k in $target.download.Keys) { $download[$k] = $target.download[$k] } } # provider coherence $provider = $download.provider if ($provider) { switch ($provider) { { $_ -in @('github', 'gitlab', 'gitlab-artifact') } { if (-not $download.owner) { $errors += "[$($target.id)] provider $provider requires 'owner'" } if (-not $download.repo) { $errors += "[$($target.id)] provider $provider requires 'repo'" } } { $_ -in @('url', 'webscrapper') } { if (-not $download.url) { $errors += "[$($target.id)] provider $provider requires 'url'" } } 'gitlab-artifact' { if (-not $download.artifact) { $errors += "[$($target.id)] provider gitlab-artifact requires 'artifact'" } elseif (-not $download.artifact.ref -or -not $download.artifact.job) { $errors += "[$($target.id)] artifact requires 'ref' and 'job'" } } } } # regex validity + payload/pattern heuristic foreach ($asset in @($download.assets)) { foreach ($pattern in @($asset.pattern, $asset.'signature-file'.pattern)) { if (-not $pattern) { continue } $clean = $pattern -replace '\{\{.*?\}\}', '' try { $null = [regex]::new($clean) } catch { $errors += "[$($target.id)] invalid regex pattern : $pattern" } } if ($target.payload -and $asset.pattern) { $suffix = switch ($target.payload) { 'tar.gz' { '\.tar\.gz' } default { "\.$($target.payload)" } } $literal = $asset.pattern -replace '\{\{.*?\}\}', '' $extMatch = [regex]::Match($literal, '\\\.(?<ext>[a-zA-Z0-9.]+)$') if ($extMatch.Success) { $declaredExt = $extMatch.Groups['ext'].Value.ToLower() $expectedExt = ($suffix -replace '^\\\.','') if ($declaredExt -ne $expectedExt -and $declaredExt -ne $expectedExt.Split('.')[0]) { $warnings += "[$($target.id)] payload '$($target.payload)' does not look like the pattern extension '$declaredExt'" } } } } # check expression coherence (merged view : target wins over global) $check = $target.check ?? $raw.check if ($check) { $errors += @(Get-GenericPackageCheckIssues -Expression $check -Prefix "[$($target.id)] ") # {{ ASSET }} only exists after the download phase if (($check | ConvertTo-Json -Depth 10 -Compress) -match '\{\{\s*ASSET\s*\}\}') { $warnings += "[$($target.id)] check uses '{{ ASSET }}' which is only available after the download phase (the pre-check cannot rely on it)" } } # upload coherence if ($target.upload) { if ($target.upload.type -eq 'nexus' -and -not $target.upload.repository) { $errors += "[$($target.id)] upload type 'nexus' requires 'repository'" } if (-not $target.upload.url) { $errors += "[$($target.id)] upload requires 'url'" } } } $valid = -not $errors.Count foreach ($w in $warnings) { Write-Warning $w } return [PSCustomObject]@{ Valid = $valid; Errors = $errors; Warnings = $warnings } } End { Write-LeaveFunction } } # ═══════════════════════════════════════════════════════════════════════════════ # DISCOVER : find pkgdef files on disk # ═══════════════════════════════════════════════════════════════════════════════ <# .SYNOPSIS Find pkgdef files on disk. .DESCRIPTION Scans a directory (or a single file) for PwSh.Fw.GenericPackageEngine package definitions. A file qualifies when it parses as YAML and carries the required format marker 'pkgdef: PwSh.Fw.GenericPackageEngine/v2' ; foreign YAML files (Ansible, GitLab CI, ...) are ignored. With -Validate, each candidate is additionally checked with Test-GenericPackageDefinition and only valid definitions are returned (invalid ones are reported as warnings). Returns the full paths of the found definitions. .EXAMPLE $files = Find-GenericPackageDefinition -Path /opt/pkgdefs -Recurse foreach ($file in $files) { Install-GenericPackage -Path $file -TargetId 'linux-x64' -DestinationPath /mnt/chroot } .EXAMPLE # Keep only the definitions that pass the semantic validation Find-GenericPackageDefinition -Path ./definitions -Recurse -Validate #> function Find-GenericPackageDefinition { [CmdletBinding()] [OutputType([string[]])] Param ( # Path to a pkgdef file or a directory to scan [Parameter(Mandatory = $true)][string]$Path, # Scan subdirectories (directory input only) [switch]$Recurse, # Additionally run Test-GenericPackageDefinition and keep only valid definitions [switch]$Validate ) Begin { Write-EnterFunction } Process { if (-not (Test-Path $Path)) { Write-Fatal "Path not found : $Path" } $candidates = @() if (Test-Path $Path -PathType Container) { $candidates = @(Get-ChildItem -Path $Path -File -Recurse:$Recurse | Where-Object { $_.Extension -in @('.yml', '.yaml') }) } else { $candidates = @(Get-Item $Path) } $found = @() foreach ($file in $candidates) { $rawText = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue if ([string]::IsNullOrEmpty($rawText)) { continue } # cheap pre-filter : the marker line must appear in the raw text # (constant value, dots escaped for regex) if ($rawText -notmatch '(?m)^\s*pkgdef:\s*PwSh\.Fw\.GenericPackageEngine/v2\s*(?:#.*)?$') { Write-Verbose "Not a pkgdef (no format marker) : $($file.FullName)" continue } # definitive check : parse and verify the key try { $def = $rawText | ConvertFrom-Yaml | ConvertTo-PlainHashtable } catch { Write-Warning "Invalid YAML, skipped : $($file.FullName)" continue } if ($def.pkgdef -ne $Script:PkgDefFormat) { Write-Verbose "Unsupported format version, skipped : $($file.FullName)" continue } if ($Validate) { $result = Test-GenericPackageDefinition -Definition $def if (-not $result.Valid) { Write-Warning "Invalid pkgdef, skipped : $($file.Name) ($(($result.Errors) -join '; '))" continue } } $found += $file.FullName Write-Verbose "Found pkgdef : $($file.FullName)" } return $found } End { Write-LeaveFunction } } # ═══════════════════════════════════════════════════════════════════════════════ # PHASE 2 : DOWNLOAD # Download the asset of a resolved definition # ═══════════════════════════════════════════════════════════════════════════════ <# .SYNOPSIS Download the asset of a resolved package definition. .DESCRIPTION Dispatches to the provider implementation (github, gitlab, gitlab-artifact, url, webscrapper) using the resolved download block. The API token is read from the environment variable named by 'token_env' (never stored in the definition). The asset is cached : if the file already exists in the cache directory, the download is skipped. When the matched asset declares a signature-file, the companion checksum file is downloaded from the same source and the asset is verified before being returned. Returns the local path of the downloaded asset. .EXAMPLE $asset = Download-GenericPackageAsset -pkgdef $resolved -CachePath '/tmp/cache' #> function Download-GenericPackageAsset { [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification="Download is the intended verb for this function.")] [CmdletBinding()] [OutputType([String])] Param ( # Resolved package definition (output of Resolve-GenericPackageDefinition) [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][object]$pkgdef, # Path to the cache directory to download assets to [Parameter(Mandatory = $false)][string]$CachePath, # Template values (uppercase keys, mustache compatible) [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{} ) Begin { Write-EnterFunction } Process { if (-not $pkgdef.download) { Write-Warning "No download configuration found" return $null } # Resolve CachePath: parameter > pkgdef.download.destination > temp path if ([string]::IsNullOrEmpty($CachePath)) { if ($pkgdef.download.destination) { $CachePath = $pkgdef.download.destination } else { $CachePath = [io.path]::GetTempPath() } } if (!(Test-Path -Path $CachePath)) { $null = New-Item -Path $CachePath -ItemType Directory -Force } $token = Get-GenericPackageToken -pkgdef $pkgdef # Seed mustache values from the resolved definition # (caller-provided values win over the derived ones) $mv = Get-GenericPackageMustacheValues -pkgdef $pkgdef -overrides $mustacheValues Write-Verbose "Downloading asset using provider '$($pkgdef.download.provider)'" Write-Devel -InputObject $pkgdef.download switch ($pkgdef.download.provider) { 'github' { $asset = Download-AssetFromGithub -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv } 'gitlab' { $asset = Download-AssetFromGitlab -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com") -GitLabToken $token } 'gitlab-artifact' { $asset = Download-ArtifactFromGitlab -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com") -GitLabToken $token } 'url' { $asset = Download-AssetFromUrl -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv } 'webscrapper' { $asset = Download-AssetFromWebScrapper -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv } default { Write-Fatal "Unknown download provider: '$($pkgdef.download.provider)'" return $null } } if ($asset -and (Test-FileExist $asset)) { return $asset } else { Write-Fatal "Failed to download asset" return $null } } End { Write-LeaveFunction } } <# .SYNOPSIS Get the asset information of a resolved package definition without downloading. .DESCRIPTION Inspects the download configuration and resolves which asset would be downloaded, returning metadata instead of performing actual downloads : - name : asset file name - download_url : URL to download from - local_path : expected local file path (cache / file name) - digest : hash digest when available (github) .EXAMPLE $assets = Get-GenericPackageAssetInfo -pkgdef $resolved -CachePath '/tmp/cache' #> function Get-GenericPackageAssetInfo { [CmdletBinding()] [OutputType([array])] Param ( # Resolved package definition [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][object]$pkgdef, # Cache directory (used to compute local_path) [Parameter(Mandatory = $false)][string]$CachePath, # Template values (uppercase keys, mustache compatible) [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{} ) Begin { Write-EnterFunction } Process { if (-not $pkgdef.download) { Write-Warning "No download configuration found" return @() } if ([string]::IsNullOrEmpty($CachePath)) { $CachePath = $pkgdef.download.destination ?? [io.path]::GetTempPath() } $token = Get-GenericPackageToken -pkgdef $pkgdef # Seed mustache values from the resolved definition # (caller-provided values win over the derived ones) $mv = Get-GenericPackageMustacheValues -pkgdef $pkgdef -overrides $mustacheValues switch ($pkgdef.download.provider) { 'github' { $assets = Get-AssetInfoFromGithub -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv } 'gitlab' { $assets = Get-AssetInfoFromGitlab -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com") -GitLabToken $token } 'gitlab-artifact' { $assets = Get-AssetInfoFromGitlabArtifact -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com") } 'url' { $assets = Get-AssetInfoFromUrl -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv } 'webscrapper' { $assets = Get-AssetInfoFromWebScrapper -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv } default { Write-Fatal "Unknown download provider: '$($pkgdef.download.provider)'" return @() } } # Normalize : always return one array object. The unary comma keeps # the array intact on the output pipeline (otherwise a single-element # result would be unwrapped into a bare hashtable by the caller) if (-not $assets) { return , @() } return , @($assets) } End { Write-LeaveFunction } } # ═══════════════════════════════════════════════════════════════════════════════ # PHASE 3 / 4 : INSTALL + POST # Execute the install steps, then the post-install steps # ═══════════════════════════════════════════════════════════════════════════════ function Execute-GenericPackageSteps { [CmdletBinding()] [OutputType([System.Boolean])] Param ( # Label of the phase (install / post) — for logging only [Parameter(Mandatory = $true)][string]$Phase, # Steps to execute (may be empty or contain null entries : both are # skipped. NB : cannot be Mandatory [object[]] — PowerShell refuses # to bind empty or null-containing arrays to mandatory array params) [Parameter(Mandatory = $false)][object[]]$Steps, # Destination path (chroot / mount / install root) [Parameter(Mandatory = $false)][string]$DestinationPath, # Template values (uppercase keys, mustache compatible) [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{} ) Begin { Write-EnterFunction } Process { if (-not $Steps -or $Steps.Count -eq 0) { Write-Verbose "No $Phase steps defined" return $true } foreach ($step in $Steps) { if ($null -eq $step) { continue } if ($step -eq @{}) { continue } Write-Verbose "Executing $Phase step '$($step.action)'" Write-Devel -InputObject $step $rc = Execute-GenericPackageStep -step $step -DestinationPath $DestinationPath -mustacheValues $mustacheValues if (-not $rc) { Write-Warning "$Phase step '$($step.action)' failed" return $false } } return $true } End { Write-LeaveFunction } } <# .SYNOPSIS Execute one installation step. .DESCRIPTION Internal step executor shared by the install and post phases. Supported actions : apt, copy, delete, exec, exec-chroot, exec-scriptblock, file-content, file-from-template, mkdir, rename, unzip ('file' is accepted as a deprecated alias of 'file-content'). All paths are relative to -DestinationPath, except file-from-template 'source' which is relative to the pkgdef directory (an absolute path is used as-is). Mustache templating is applied on text fields. #> function Execute-GenericPackageStep { [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification="Execute is an intuitive verb for this function. We could use Invoke but the meaning is not that.")] [CmdletBinding()] [OutputType([System.Boolean])] Param ( # Step definition from yaml [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][hashtable]$step, # Destination path to install : typically a LinuxPE chroot or a WinPE mount path [Parameter(Mandatory = $false)][string]$DestinationPath, # Template values : single level, uppercased keys hashtable suitable for mustache {{ VALUE }} [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{} ) Begin { Write-EnterFunction } Process { Write-Devel -InputObject $mustacheValues $rc = $false # 'file' is a deprecated alias of 'file-content' (kept for migration) $action = $step.action if ($action -eq 'file') { # native warning stream (the framework override is display-only) # so the notice stays machine-readable and CI-greppable & Microsoft.PowerShell.Utility\Write-Warning "Step action 'file' is deprecated : use 'file-content' instead" $action = 'file-content' } switch ($action) { 'apt' { $script = @" EOF export DEBIAN_FRONTEND=noninteractive apt-get update [ `$? -eq 0 ] || exit `$? apt-get install --no-install-recommends -y $($step.packages -join ' ') [ `$? -eq 0 ] || exit `$? EOF "@ $rc = Execute-Command -exe "/bin/bash" "-c `"LANG=C.UTF-8 chroot $DestinationPath <<- $script`"" -AsBool } 'copy' { $source = Render-MustacheField -value $step.source -mustacheValues $mustacheValues $destination = Render-MustacheField -value $step.destination -mustacheValues $mustacheValues $destFile = "$DestinationPath/$destination" $parent = Split-Path $destFile -Parent if ($parent -and -not (Test-DirExist $parent)) { $null = New-Item -Path $parent -ItemType Directory -Force } $null = Copy-Item -Path "$DestinationPath/$source" -Destination $destFile -Force $rc = Test-Path $destFile -PathType Leaf } 'delete' { $path = Render-MustacheField -value $step.path -mustacheValues $mustacheValues $target = "$DestinationPath/$($path.Trim('/'))" if (Test-Path $target) { Remove-Item -Path $target -Recurse -Force } $rc = -not (Test-Path $target) } 'exec' { # 2 cases : # - user provides cmd: attribute with whole command # - user provides exe: and args: as separate attributes if ($step.cmd) { $command = Render-MustacheField -value $step.cmd -mustacheValues $mustacheValues $workingDir = if ($step.workingDir) { "$DestinationPath/$($step.workingDir)" } else { $DestinationPath } $rc = Execute-Command $command -WorkingDir $workingDir -AsBool } else { $exe = Render-MustacheField -value $step.exe -mustacheValues $mustacheValues $parameters = Render-MustacheField -value $step.args -mustacheValues $mustacheValues $workingDir = if ($step.workingDir) { "$DestinationPath/$($step.workingDir)" } else { $DestinationPath } $rc = Execute-Command -exe $exe -args $parameters -WorkingDir $workingDir -AsBool } } 'exec-chroot' { $script = Render-MustacheField -value $step.script -mustacheValues $mustacheValues $script = @" EOF $script EOF "@ $rc = Execute-Command -exe "/bin/bash" "-c `"LANG=C.UTF-8 chroot $DestinationPath <<- $script`"" -AsBool } 'exec-scriptblock' { $scriptblock = Render-MustacheField -value $step.scriptblock -mustacheValues $mustacheValues $output = Invoke-Command -ScriptBlock ([scriptblock]::Create($scriptblock)) if ($step.variable) { $mustacheValues[$($step.variable)] = $output.ToString() } $rc = $true } 'file-content' { $filepath = Render-MustacheField -value $step.filepath -mustacheValues $mustacheValues $content = Render-MustacheField -value $step.content -mustacheValues $mustacheValues $parent = Split-Path "$DestinationPath/$filepath" -Parent if ($parent -and -not (Test-DirExist $parent)) { $null = New-Item -Path $parent -ItemType Directory -Force } $null = Set-Content -Path "$DestinationPath/$filepath" -Value $content $rc = Test-Path "$DestinationPath/$filepath" -PathType Leaf } 'file-from-template' { $source = Render-MustacheField -value $step.source -mustacheValues $mustacheValues # a relative source is resolved against the pkgdef directory # (PKGDEFDIR is set by Install from -Path) if (-not [System.IO.Path]::IsPathRooted($source)) { if (-not $mustacheValues['PKGDEFDIR']) { Write-Fatal "file-from-template : relative 'source' requires PKGDEFDIR : run Install-GenericPackage with -Path, or provide PKGDEFDIR via -mustacheValues" } $source = "$($mustacheValues['PKGDEFDIR'])/$source" } if (-not (Test-FileExist $source)) { Write-Fatal "file-from-template : template not found : $source" } $content = Render-MustacheField -value (Get-Content -Path $source -Raw) -mustacheValues $mustacheValues $destination = Render-MustacheField -value $step.destination -mustacheValues $mustacheValues $destFile = "$DestinationPath/$destination" $parent = Split-Path $destFile -Parent if ($parent -and -not (Test-DirExist $parent)) { $null = New-Item -Path $parent -ItemType Directory -Force } $null = Set-Content -Path $destFile -Value $content $rc = Test-Path $destFile -PathType Leaf } 'mkdir' { $null = New-Item -Path "$DestinationPath/$($step.path.Trim('/'))" -ItemType Directory -ErrorAction SilentlyContinue $rc = Test-Path "$DestinationPath/$($step.path.Trim('/'))" -PathType Container } 'rename' { $source = Render-MustacheField -value $step.source -mustacheValues $mustacheValues $newName = Render-MustacheField -value $step.newname -mustacheValues $mustacheValues $srcFile = "$DestinationPath/$source" $parentDir = Split-Path $srcFile -Parent $null = Rename-Item -Path $srcFile -NewName $newName $rc = Test-Path "$parentDir/$newName" } 'unzip' { $source = Render-MustacheField -value $step.source -mustacheValues $mustacheValues $null = New-Item -Path "$DestinationPath/$($step.destination)" -ItemType Directory -ErrorAction SilentlyContinue $null = Expand-Archive -Path $source -DestinationPath "$DestinationPath/$($step.destination)" $rc = Test-Path "$DestinationPath/$($step.destination)" -PathType Container } default { Write-Fatal "Unknown step action : '$($step.action)'" } } return $rc } End { Write-LeaveFunction } } # ═══════════════════════════════════════════════════════════════════════════════ # PHASE 5 : UPLOAD # Publish the downloaded asset to a server # ═══════════════════════════════════════════════════════════════════════════════ function Invoke-GenericPackageUpload { [CmdletBinding()] [OutputType([System.Boolean])] Param ( # Upload block (target.upload of the resolved definition) [Parameter(Mandatory = $true)][hashtable]$upload, # Local file to upload [Parameter(Mandatory = $true)][string]$Source, # Template values (uppercase keys, mustache compatible) [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{} ) Begin { Write-EnterFunction } Process { $url = Render-MustacheField -value $upload.url -mustacheValues $mustacheValues $filename = $null if ($upload.filename) { $filename = Render-MustacheField -value $upload.filename -mustacheValues $mustacheValues } $username = $upload.username $password = '' if ($upload.password_env) { $password = [Environment]::GetEnvironmentVariable($upload.password_env) if ([string]::IsNullOrEmpty($password)) { Write-Fatal "Environment variable '$($upload.password_env)' is empty or not set" } } $headers = @{} if ($username) { $auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${username}:${password}")) $headers['Authorization'] = "Basic $auth" } switch ($upload.type) { 'dufs' { $targetUrl = $url if ($filename) { $targetUrl = "$($url.TrimEnd('/'))/$filename" } Write-Verbose "Uploading $Source to $targetUrl (dufs/WebDAV)" $null = Invoke-WebRequest -Uri $targetUrl -Method Put -InFile $Source -Headers $headers -UseBasicParsing $rc = $? } 'nexus' { if (-not $upload.repository) { Write-Fatal "upload.repository is required for type 'nexus'" } $leaf = $filename ?? [IO.Path]::GetFileName($Source) $targetUrl = "$($url.TrimEnd('/'))/service/rest/v1/raw/$($upload.repository)/$leaf" Write-Verbose "Uploading $Source to $targetUrl (nexus)" $null = Invoke-WebRequest -Uri $targetUrl -Method Put -InFile $Source -Headers $headers -UseBasicParsing $rc = $? } 'smb' { $leaf = $filename ?? [IO.Path]::GetFileName($Source) $dest = "$url/$leaf" Write-Verbose "Copying $Source to $dest (smb)" # Note : SMB authentication relies on the current session credentials $null = Copy-Item -Path $Source -Destination $dest -Force $rc = $? } 'ftp' { $uri = [uri]$url $leaf = $filename ?? [IO.Path]::GetFileName($Source) $remotePath = "$($uri.AbsolutePath.TrimEnd('/'))/$leaf" Write-Verbose "Uploading $Source to ${uri.Host}$remotePath (ftp)" $client = New-Object System.Net.FtpClient $client.Host = $uri.Host if ($uri.Port) { $client.Port = $uri.Port } if ($uri.Scheme -eq 'ftps') { $client.EnableSsl = $true } if ($username) { $client.Credentials = New-Object System.Net.NetworkCredential($username, $password) } try { $null = $client.UploadFile($Source, $remotePath) $rc = $? } finally { $client.Dispose() } } default { Write-Fatal "Unknown upload type : '$($upload.type)'" $rc = $false } } return $rc } End { Write-LeaveFunction } } # ═══════════════════════════════════════════════════════════════════════════════ # WRAPPER : Complete workflow # resolve → check → download → verification → install → post → upload → check # ═══════════════════════════════════════════════════════════════════════════════ <# .SYNOPSIS Install a package from a pkgdef v2 definition. .DESCRIPTION Main entry point orchestrating the full pipeline for one target : 1. Resolve : resolve the definition for the selected target ('latest' is resolved to the provider's current version via -ResolveLatest, so {{ VERSION }} is concrete from the pre-check on) 2. Check : pre-install check — when the expression is true the package is considered already installed and the pipeline is skipped (Status=Success, Skipped=$true) ; -Force continues anyway 3. Download : download the asset (+ digest / signature verification) 4. Install : execute the install steps 5. Post : execute the post-install steps 6. Upload : publish the asset when the target declares an upload block 7. Check : post-install check — must be true, otherwise the target fails (Phase='Check') ; -NoCheck skips both check phases Returns a result hashtable : Status (Success/Failed), Phase, Error, Skipped, Name, Codename, Version, Target, Asset. .EXAMPLE $result = Install-GenericPackage -Path ./examples/aria2c.yml -TargetId windows-x64 -DestinationPath 'C:\' .EXAMPLE $result = Install-GenericPackage -Path ./examples/libreoffice.yml -Os linux -Distro debian -Arch x64 -DestinationPath /mnt/chroot #> function Install-GenericPackage { [CmdletBinding()] [OutputType([hashtable])] Param ( # Path to the pkgdef file [Parameter(Mandatory = $false)][string]$Path, # Package definition (hashtable) [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition, # Explicit target id [Parameter(Mandatory = $false)][string]$TargetId, # Target OS (alternative to -TargetId) [Parameter(Mandatory = $false)][ValidateSet('windows', 'linux', 'macos')][string]$Os, # Target distribution (linux) [Parameter(Mandatory = $false)][string]$Distro = '', # Target architecture [Parameter(Mandatory = $false)][ValidateSet('x86', 'x64', 'arm32', 'arm64', 'all')][string]$Arch = '', # Destination path to install (chroot / mount / install root) [Parameter(Mandatory = $false)][string]$DestinationPath, # Path to the cache directory to download assets to [Parameter(Mandatory = $false)][string]$CachePath, # Template values (uppercase keys, mustache compatible) [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}, # Continue the pipeline even when the pre-check reports the package as already installed [Parameter(Mandatory = $false)][switch]$Force, # Skip both check phases (pre and post) [Parameter(Mandatory = $false)][switch]$NoCheck ) Begin { Write-EnterFunction } Process { if (-not $Path -and -not $Definition) { Write-Fatal "Either -Path or -Definition must be provided" } if ($Path -and $Definition) { Write-Fatal "Provide either -Path or -Definition, not both" } # ─── Load raw definition ────────────────────────────────────────────── if ($Path) { if (-not (Test-FileExist $Path)) { Write-Fatal "File not found : $Path" } $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable } else { $raw = ConvertTo-PlainHashtable -InputObject $Definition } $result = @{ Status = '' Phase = '' Error = '' Skipped = $false Name = $raw.name Codename = $raw.codename ?? (($raw.name.ToLower() -replace '\s+', '-')) Version = '' Target = $TargetId ?? "$Os/$Distro/$Arch" Asset = '' } try { # ─── Phase 1 : Resolve ──────────────────────────────────────────── Write-Verbose "Phase 1 : resolving definition" # target selection splat : -Os/-Distro/-Arch are only valid when # -TargetId is not used (empty values would fail ValidateSet) $targetParams = @{} if ($TargetId) { $targetParams.TargetId = $TargetId } else { $targetParams.Os = $Os $targetParams.Distro = $Distro $targetParams.Arch = $Arch } try { # -ResolveLatest : an install is online by nature, and a # 'latest' version must be concrete for the pre-check (and # the result) to make sense $resolved = Resolve-GenericPackageDefinition -Definition $raw -ResolveLatest @targetParams } catch { $resolved = $null Write-Warning $_.Exception.Message } if (-not $resolved) { $result.Status = 'Failed'; $result.Phase = 'Resolve'; $result.Error = 'definition could not be resolved' return $result } $result.Target = $resolved.target.id $result.Version = $resolved.version # Seed mustache values from the resolved definition # (caller-provided values win over the derived ones) $mv = Get-GenericPackageMustacheValues -pkgdef $resolved -overrides $mustacheValues $mv.DEST = $DestinationPath # directory of the pkgdef file : relative file-from-template # sources resolve against it ; an explicit caller value wins if ($Path -and -not $mustacheValues['PKGDEFDIR']) { $mv.PKGDEFDIR = Split-Path $Path -Parent } Write-Devel -InputObject $mv # Resolve CachePath: parameter > pkgdef.download.destination > temp path if ([string]::IsNullOrEmpty($CachePath)) { if ($resolved.download.destination) { $CachePath = $resolved.download.destination } else { $CachePath = [io.path]::GetTempPath() } } # ─── Phase 2 : Check (pre) ──────────────────────────────────────── if (-not $NoCheck -and $resolved.check) { Write-Verbose "Phase 2 : evaluating pre-install check" if (Test-GenericPackageCheck -Expression $resolved.check -DestinationPath $DestinationPath -mustacheValues $mv) { if ($Force) { Write-Verbose "Pre-check passed but -Force given : continuing the pipeline" } else { Write-Verbose "Pre-check passed : package already installed, skipping the pipeline" $result.Status = 'Success'; $result.Phase = 'Check'; $result.Skipped = $true return $result } } } # ─── Phase 3 : Download (+ verification) ────────────────────────── Write-Verbose "Phase 3 : downloading asset" try { $asset = Download-GenericPackageAsset -pkgdef $resolved -CachePath $CachePath -mustacheValues $mv } catch { $asset = $null Write-Warning $_.Exception.Message } if (-not $asset) { $result.Status = 'Failed'; $result.Phase = 'Download'; $result.Error = 'asset download failed' return $result } $result.Asset = $asset $mv.ASSET = $asset # a provider may have refined the version during download # (webscrapper ?<ver> capture) : reflect it in the result $result.Version = $mv.VERSION Write-Verbose "Downloaded asset : $asset" # ─── Phase 4 : Install ──────────────────────────────────────────── Write-Verbose "Phase 4 : executing install steps" $rc = Execute-GenericPackageSteps -Phase 'install' -Steps @($resolved.install) -DestinationPath $DestinationPath -mustacheValues $mv if (-not $rc) { $result.Status = 'Failed'; $result.Phase = 'Install'; $result.Error = 'install step execution failed' return $result } # ─── Phase 5 : Post ─────────────────────────────────────────────── Write-Verbose "Phase 5 : executing post-install steps" $rc = Execute-GenericPackageSteps -Phase 'post' -Steps @($resolved.post) -DestinationPath $DestinationPath -mustacheValues $mv if (-not $rc) { $result.Status = 'Failed'; $result.Phase = 'Post'; $result.Error = 'post-install step execution failed' return $result } # ─── Phase 6 : Upload ───────────────────────────────────────────── if ($resolved.upload) { Write-Verbose "Phase 6 : uploading asset" $rc = Invoke-GenericPackageUpload -upload $resolved.upload -Source $asset -mustacheValues $mv if (-not $rc) { $result.Status = 'Failed'; $result.Phase = 'Upload'; $result.Error = 'asset upload failed' return $result } } # ─── Phase 7 : Check (post) ─────────────────────────────────────── if (-not $NoCheck -and $resolved.check) { Write-Verbose "Phase 7 : evaluating post-install check" if (-not (Test-GenericPackageCheck -Expression $resolved.check -DestinationPath $DestinationPath -mustacheValues $mv)) { $result.Status = 'Failed'; $result.Phase = 'Check'; $result.Error = 'post-install check failed' return $result } } $result.Status = 'Success' return $result } catch { Write-Warning "Unexpected error: $_" $result.Status = 'Failed' $result.Phase = 'Unknown' $result.Error = $_.Exception.Message return $result } } End { Write-LeaveFunction } } # ═══════════════════════════════════════════════════════════════════════════════ # PRIVATE HELPERS # ═══════════════════════════════════════════════════════════════════════════════ <# .SYNOPSIS Recursively convert a definition into plain hashtables / object arrays. .DESCRIPTION powershell-yaml (and PwSh.Fw.Core ConvertTo-Hashtable) produce PSCustomObject and OrderedDictionary instances. Plain hashtables are safer downstream : they bind to [hashtable] parameters and behave identically everywhere. #> function ConvertTo-PlainHashtable { [CmdletBinding()] [OutputType([object])] Param ( [AllowNull()][Parameter(Mandatory = $true, ValueFromPipeline = $true)][object]$InputObject ) Begin { Write-EnterFunction } Process { if ($null -eq $InputObject) { return $null } if ($InputObject -is [System.Collections.IDictionary]) { $h = @{} foreach ($k in $InputObject.Keys) { $h[$k] = ConvertTo-PlainHashtable -InputObject $InputObject[$k] } return $h } if ($InputObject -is [System.Management.Automation.PSCustomObject]) { $h = @{} foreach ($prop in $InputObject.PSObject.Properties) { $h[$prop.Name] = ConvertTo-PlainHashtable -InputObject $prop.Value } return $h } if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { $arr = @() foreach ($item in $InputObject) { $arr += ConvertTo-PlainHashtable -InputObject $item } return , $arr } return $InputObject } End { Write-LeaveFunction } } <# .SYNOPSIS Key-by-key merge of two download blocks (target keys win). #> function Merge-GenericPackageDownload { [CmdletBinding()] [OutputType([hashtable])] Param ( [Parameter(Mandatory = $false)][object]$Global, [Parameter(Mandatory = $false)][object]$Override ) Begin { Write-EnterFunction } Process { # NB : -is [hashtable] would miss OrderedDictionary (what # ConvertTo-Hashtable returns for yaml input), hence IDictionary $merged = @{} if ($Global -is [System.Collections.IDictionary]) { foreach ($k in $Global.Keys) { $merged[$k] = $Global[$k] } } if ($Override -is [System.Collections.IDictionary]) { foreach ($k in $Override.Keys) { $merged[$k] = $Override[$k] } } return $merged } End { Write-LeaveFunction } } <# .SYNOPSIS Read the API token from the environment variable named by download.token_env. Tokens are never stored in the definition. #> function Get-GenericPackageToken { [CmdletBinding()] [OutputType([string])] Param ( [Parameter(Mandatory = $true)][object]$pkgdef ) Begin { Write-EnterFunction } Process { $token = '' if ($pkgdef.download.token_env) { $token = [Environment]::GetEnvironmentVariable($pkgdef.download.token_env) if ([string]::IsNullOrEmpty($token)) { Write-Warning "token_env '$($pkgdef.download.token_env)' is empty or not set" } } return $token } End { Write-LeaveFunction } } <# .SYNOPSIS Build the mustache values table for a resolved definition. .DESCRIPTION Seeds the standard keys (NAME, CODENAME, VERSION, OS, DISTRO, ARCH) from the resolved definition, then applies the caller-provided values on top : explicit values win over the derived ones, like the key-by-key download merge. TAGNAME is added later by the providers once the release is resolved ; DEST and ASSET are added by Install-GenericPackage. #> function Get-GenericPackageMustacheValues { [CmdletBinding()] [OutputType([hashtable])] Param ( [Parameter(Mandatory = $true)][object]$pkgdef, [Parameter(Mandatory = $false)][hashtable]$overrides = @{} ) Begin { Write-EnterFunction } Process { $mv = @{} if ($pkgdef.name) { $mv.NAME = $pkgdef.name } if ($pkgdef.codename) { $mv.CODENAME = $pkgdef.codename } if ($pkgdef.version) { $mv.VERSION = $pkgdef.version } if ($pkgdef.target) { if ($pkgdef.target.id) { $mv.TARGETID = $pkgdef.target.id } if ($pkgdef.target.os) { $mv.OS = $pkgdef.target.os } if ($pkgdef.target.distro) { $mv.DISTRO = $pkgdef.target.distro } if ($pkgdef.target.arch) { $mv.ARCH = $pkgdef.target.arch } } foreach ($k in $overrides.Keys) { $mv[$k] = $overrides[$k] } return $mv } End { Write-LeaveFunction } } <# .SYNOPSIS Render a mustache template field when it contains placeholders. #> function Render-MustacheField { [CmdletBinding()] [OutputType([string])] Param ( [AllowNull()][AllowEmptyString()][string]$value, [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{} ) Begin { Write-EnterFunction } Process { if ([string]::IsNullOrEmpty($value)) { return $value } if (Test-MustacheTemplate $value) { return ConvertFrom-MustacheTemplate -Template $value -values $mustacheValues } return $value } End { Write-LeaveFunction } } <# .SYNOPSIS Select the first asset matching the ordered pattern list. .DESCRIPTION Shared pattern-matching loop for the providers. -Names are the candidate names (release asset names or page link URLs), -Assets the corresponding objects, -AssetSpecs the ordered list of download.assets entries ({ pattern ; signature-file }). Returns @{ Index ; Spec } or $null. #> function Select-GenericPackageAsset { [CmdletBinding()] [OutputType([hashtable])] Param ( [Parameter(Mandatory = $true)][string[]]$Names, [Parameter(Mandatory = $true)][object[]]$Assets, # may be absent : a definition without assets[] cannot match anything [Parameter(Mandatory = $false)][object[]]$AssetSpecs, [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}, # when the matched pattern defines a named group 'ver', the candidate # holding the highest captured version wins instead of the first match [switch]$PickHighestVersion ) Begin { Write-EnterFunction } Process { # filter null entries : -match against a null pattern would match anything $specs = @($AssetSpecs | Where-Object { $null -ne $_ }) if (-not $specs.Count) { return $null } foreach ($spec in $specs) { $pattern = $spec.pattern if (Test-MustacheTemplate $pattern) { $pattern = ConvertFrom-MustacheTemplate -Template $pattern -values $mustacheValues } Write-Devel "Looking for asset matching pattern '$pattern'" $candidates = @() for ($i = 0; $i -lt $Names.Count; $i++) { if ($Names[$i] -match $pattern) { if (-not $PickHighestVersion) { return @{ Index = $i; Spec = $spec; CapturedVersion = $null } } $candidate = @{ Index = $i; Captured = $Matches['ver'] } try { $candidate.V = [version]$Matches['ver'] } catch { $candidate.V = $null } $candidates += $candidate } } if ($candidates.Count) { # highest parseable captured version wins, else first match $withVersion = @($candidates | Where-Object { $null -ne $_.V } | Sort-Object -Property V -Descending) if ($withVersion.Count) { return @{ Index = $withVersion[0].Index; Spec = $spec; CapturedVersion = $withVersion[0].Captured } } return @{ Index = $candidates[0].Index; Spec = $spec; CapturedVersion = $null } } } return $null } End { Write-LeaveFunction } } <# .SYNOPSIS Verify a downloaded asset against a signature (checksum) file. .DESCRIPTION The signature file must contain the asset's hex hash : the first line starting with a hex string is used (bare '<hash>', or sha256sum style '<hash> <filename>'). Binary formats (e.g. GPG detached signatures) are not supported. #> function Test-GenericPackageSignature { [CmdletBinding()] [OutputType([System.Boolean])] Param ( [Parameter(Mandatory = $true)][string]$AssetPath, [Parameter(Mandatory = $true)][string]$SignaturePath, [Parameter(Mandatory = $true)][ValidateSet('md5', 'sha1', 'sha256', 'sha512')][string]$Algorithm ) Begin { Write-EnterFunction } Process { if (-not (Test-FileExist $SignaturePath)) { Write-Error "Signature file not found : $SignaturePath" return $false } $expected = $null foreach ($line in (Get-Content $SignaturePath)) { if ($line -match '^\s*(?<hash>[0-9a-fA-F]{8,})\b') { $expected = $Matches.hash.ToUpper() break } } if (-not $expected) { Write-Error "Could not extract a hash from signature file : $SignaturePath" return $false } $actual = (Get-FileHash -Algorithm $Algorithm.ToUpper() -Path $AssetPath).Hash.ToUpper() if ($actual -eq $expected) { Write-Success "Signature ($Algorithm) verified : $([IO.Path]::GetFileName($AssetPath))" return $true } Write-Error "Signature mismatch ($Algorithm) for $([IO.Path]::GetFileName($AssetPath)) : expected $expected, got $actual" return $false } End { Write-LeaveFunction } } <# .SYNOPSIS Convert an object or a hashtable to a mustache values hashtable. .DESCRIPTION Converts only the 1st level of object/hashtable attributes and uppercases the key names so you can use {{ KEY }} to increase readability. #> function ConvertTo-MustacheValuesHashtable { [CmdletBinding()] [OutputType([hashtable])] Param ( # Object to convert to an uppercased keys, single level hashtable [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][object]$InputObject ) Begin { Write-EnterFunction } Process { Write-Debug "Convert object to hashtable" if ($InputObject -is [hashtable]) { $hash = $InputObject } else { $hash = $InputObject | ConvertTo-Hashtable } Write-Debug "Uppercase keys and trim collections values" $HASHTABLE = @{} foreach ($key in $hash.Keys) { Write-Devel "$key = $($hash.$key)" if ($hash.$key -isnot [System.Collections.IEnumerable] -or $hash.$key -is [string]) { $upperKey = $key.ToUpper() $HASHTABLE.$upperKey = $hash.$key } } return $HASHTABLE } End { Write-LeaveFunction } } function Test-MustacheTemplate { [CmdletBinding()] [OutputType([boolean])] Param ( [AllowEmptyString()][AllowNull()] [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][string]$string ) Begin { Write-EnterFunction } Process { return ($string -match '{{ [a-zA-Z0-9_.-]+ }}') } End { Write-LeaveFunction } } <# .SYNOPSIS Evaluate a pkgdef check expression against a destination path. .DESCRIPTION Recursive boolean evaluator for the 'check' block of a resolved definition : - and : true when every child expression is true - or : true when at least one child expression is true - not : true when the child expression is false - file-exists : leaf, tests a path relative to -DestinationPath (mustache rendered) ; type file (default) | dir | any Structural problems (unknown node, wrong arity, missing path) are reported as warnings and evaluate to $false — fail-closed : a broken expression never reports "installed". #> function Test-GenericPackageCheck { [CmdletBinding()] [OutputType([System.Boolean])] Param ( # Check expression node (hashtable from a resolved definition) [Parameter(Mandatory = $true)][object]$Expression, # Destination path the relative paths are resolved against [Parameter(Mandatory = $false)][string]$DestinationPath = '', # Template values (uppercase keys, mustache compatible) [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{} ) Begin { Write-EnterFunction } Process { # normalize : plain hashtables everywhere (the engine's own conversion) $Expression = ConvertTo-PlainHashtable -InputObject $Expression if (-not $Expression -or $Expression -isnot [System.Collections.IDictionary]) { Write-Warning "Invalid check node : expected a mapping, got '$Expression'" return $false } if ($Expression.Count -ne 1) { Write-Warning "Invalid check node : expected exactly one key" return $false } $key = @($Expression.Keys)[0] switch ($key) { 'and' { $children = @(@($Expression.and) | Where-Object { $null -ne $_ }) if ($children.Count -lt 2) { Write-Warning "Invalid check node 'and' : at least 2 children required" return $false } foreach ($child in $children) { if (-not (Test-GenericPackageCheck -Expression $child -DestinationPath $DestinationPath -mustacheValues $mustacheValues)) { return $false } } return $true } 'or' { $children = @(@($Expression.or) | Where-Object { $null -ne $_ }) if ($children.Count -lt 2) { Write-Warning "Invalid check node 'or' : at least 2 children required" return $false } foreach ($child in $children) { if (Test-GenericPackageCheck -Expression $child -DestinationPath $DestinationPath -mustacheValues $mustacheValues) { return $true } } return $false } 'not' { if (-not $Expression.not) { Write-Warning "Invalid check node 'not' : a child expression is required" return $false } return -not (Test-GenericPackageCheck -Expression $Expression.not -DestinationPath $DestinationPath -mustacheValues $mustacheValues) } 'file-exists' { $leaf = $Expression.'file-exists' if (-not $leaf -or -not $leaf.path) { Write-Warning "Invalid check node 'file-exists' : 'path' is required" return $false } $path = Render-MustacheField -value $leaf.path -mustacheValues $mustacheValues $full = "$DestinationPath/$($path.Trim('/'))" $type = $leaf.type ?? 'file' $exists = switch ($type) { 'file' { Test-Path $full -PathType Leaf } 'dir' { Test-Path $full -PathType Container } default { Test-Path $full } } # expected (config literal + mustache-resolved full path) vs found Write-Debug "check file-exists : expected '$($leaf.path)' → resolved '$full' (type '$type') ; found : $exists" return $exists } default { Write-Warning "Unknown check node : '$key'" return $false } } } End { Write-LeaveFunction } } <# .SYNOPSIS Collect structural issues of a pkgdef check expression. .DESCRIPTION Semantic validation counterpart of Test-GenericPackageCheck : walks the expression tree and returns one message per problem (node without exactly one key, and/or with fewer than 2 children, not without a child, file-exists without a path or with an unknown type). Used by Test-GenericPackageDefinition ; an empty list means the expression is structurally sound. #> function Get-GenericPackageCheckIssues { [CmdletBinding()] [OutputType([string[]])] Param ( # Check expression node (hashtable from a raw definition) [Parameter(Mandatory = $true)][object]$Expression, # Location prefix for the messages (e.g. '[windows-x64] and[0]. ') [Parameter(Mandatory = $false)][string]$Prefix = '' ) Begin { Write-EnterFunction } Process { # normalize : plain hashtables everywhere (the engine's own conversion) $Expression = ConvertTo-PlainHashtable -InputObject $Expression if (-not $Expression -or $Expression -isnot [System.Collections.IDictionary]) { return @("$Prefixinvalid check node : expected a mapping, got '$Expression'") } if ($Expression.Count -ne 1) { return @("$Prefixinvalid check node : expected exactly one key") } $key = @($Expression.Keys)[0] switch ($key) { { $_ -in @('and', 'or') } { $children = @(@($Expression.$key) | Where-Object { $null -ne $_ }) if ($children.Count -lt 2) { return @("$Prefix'$key' requires at least 2 children") } $issues = @() for ($i = 0; $i -lt $children.Count; $i++) { $issues += @(Get-GenericPackageCheckIssues -Expression $children[$i] -Prefix "$Prefix$key[$i]. ") } return $issues } 'not' { if (-not $Expression.not) { return @("$Prefix'not' requires a child expression") } return @(Get-GenericPackageCheckIssues -Expression $Expression.not -Prefix "$Prefixnot. ") } 'file-exists' { $leaf = $Expression.'file-exists' if (-not $leaf -or -not $leaf.path) { return @("$Prefix'file-exists' requires 'path'") } if ($leaf.type -and $leaf.type -notin @('file', 'dir', 'any')) { return @("$Prefix'file-exists' invalid type '$($leaf.type)' (expected file, dir or any)") } return @() } default { return @("$Prefixunknown check node : '$key'") } } } End { Write-LeaveFunction } } <# .SYNOPSIS Normalize a provider version string. .DESCRIPTION Strips a single leading 'v'/'V' prefix and trims whitespace. Everything else is kept as-is : distro build suffixes (1.2.3-deb-1, 1.2.3~deb12u1) are part of the real package version and must survive. #> function Normalize-GenericPackageVersion { [CmdletBinding()] [OutputType([string])] Param ( [AllowNull()][AllowEmptyString()][Parameter(Mandatory = $true)][string]$Version ) Begin { Write-EnterFunction } Process { if ([string]::IsNullOrWhiteSpace($Version)) { return '' } return ($Version.Trim() -replace '^[vV]', '') } End { Write-LeaveFunction } } <# .SYNOPSIS Resolve the 'latest' version of a product from its download provider. .DESCRIPTION Provider implementations : - github : latest release tag (API call) - gitlab : latest release tag (API call) - webscrapper : highest (?<ver>) capture among the page links matching the asset patterns (page scrape, no asset download) - url : local best-effort, last version-like token in the URL path (1.2.3, 1.2.3-deb-1) ; $null when the URL carries the literal 'latest' - gitlab-artifact : local, the artifact ref when it looks like a version ; $null otherwise Returns the normalized version string, or $null only for the local fallback cases above. Providers backed by an API (github / gitlab / webscrapper) Write-Fatal when they cannot produce a version : without network there would be nothing to download anyway. #> function Get-GenericPackageLatestVersion { [CmdletBinding()] [OutputType([string])] Param ( # Merged download block of the resolved definition [Parameter(Mandatory = $true)][hashtable]$download, # Template values used to render the url (VERSION is still 'latest' here) [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{} ) Begin { Write-EnterFunction } Process { switch ($download.provider) { 'github' { try { $release = Get-GithubRelease -Owner $download.owner -RepositoryName $download.repo -Latest } catch { Write-Fatal "Cannot resolve 'latest' from github $($download.owner)/$($download.repo) : $($_.Exception.Message)" } if (-not $release -or -not $release.tag_name) { Write-Fatal "Cannot resolve 'latest' : no release found on github $($download.owner)/$($download.repo)" } return (Normalize-GenericPackageVersion -Version $release.tag_name) } 'gitlab' { # Get-ReleaseFromGitlab expects the pkgdef shape (download.* + version) $synthetic = @{ download = $download; version = 'latest' } $token = Get-GenericPackageToken -pkgdef $synthetic try { $release = Get-ReleaseFromGitlab -pkgdef $synthetic -GitLabBaseUrl ($download.gitlab_url ?? 'https://gitlab.com') -GitLabToken $token } catch { Write-Fatal "Cannot resolve 'latest' from gitlab $($download.owner)/$($download.repo) : $($_.Exception.Message)" } if (-not $release -or -not $release.tag_name) { Write-Fatal "Cannot resolve 'latest' : no release found on gitlab $($download.owner)/$($download.repo)" } return (Normalize-GenericPackageVersion -Version $release.tag_name) } 'webscrapper' { try { $pageUrl = Render-MustacheField -value $download.url -mustacheValues $mustacheValues $links = Get-LinksFromWebPage -Url $pageUrl } catch { Write-Fatal "Cannot resolve 'latest' : cannot scrape page '$($download.url)' : $($_.Exception.Message)" } if (-not $links.Count) { Write-Fatal "Cannot resolve 'latest' : no http(s) link found on page '$($download.url)'" } $match = Select-GenericPackageAsset -Names $links -Assets $links -AssetSpecs @($download.assets) -mustacheValues $mustacheValues -PickHighestVersion if (-not $match) { Write-Fatal "Cannot resolve 'latest' : no link of page '$($download.url)' matches the declared patterns" } if (-not $match.CapturedVersion) { Write-Fatal "Cannot resolve 'latest' : no asset pattern defines a '(?<ver>)' capture group" } return (Normalize-GenericPackageVersion -Version $match.CapturedVersion) } 'url' { # local derivation : last version-like token in the URL path # (at least two dotted components, optional distro suffix) try { $pageUrl = Render-MustacheField -value $download.url -mustacheValues $mustacheValues $pathOnly = ([uri]$pageUrl).AbsolutePath $versionTokens = [regex]::Matches($pathOnly, '\d+\.\d+(?:\.\d+)*(?:[-~][0-9A-Za-z]+(?:[-][0-9A-Za-z]+)*)?') if ($versionTokens.Count) { return (Normalize-GenericPackageVersion -Version $versionTokens[$versionTokens.Count - 1].Value) } } catch { Write-Verbose "Cannot extract a version from URL '$($download.url)' : $($_.Exception.Message)" } return $null } 'gitlab-artifact' { # local derivation : the ref is the version when it looks like one $ref = Normalize-GenericPackageVersion -Version "$($download.artifact.ref)" if ($ref -match '^\d+(\.\d+)') { return $ref } return $null } default { return $null } } } End { Write-LeaveFunction } } # ─── Load provider implementations ──────────────────────────────────────────── # NB : providers are .ps1 files, NOT .psm1 : dot-sourcing a .psm1 file makes # PowerShell import it as a separate module instead of executing it in module # scope, so the provider functions would not be visible to this module. foreach ($include in (Get-ChildItem -Path "$PSScriptRoot/Includes" -Filter '*.ps1' -ErrorAction SilentlyContinue)) { . $include.FullName } # ─── Export public API ──────────────────────────────────────────────────────── Export-ModuleMember -Function @( 'Resolve-GenericPackageDefinition', 'Expand-GenericPackageTargets', 'Test-GenericPackageDefinition', 'Find-GenericPackageDefinition', 'Download-GenericPackageAsset', 'Get-GenericPackageAssetInfo', 'Install-GenericPackage' ) |