PwSh.Fw.GenericPackageEngine.psm1
|
#requires -version 7.0 $Script:ModuleRoot = $PSScriptRoot # ═══════════════════════════════════════════════════════════════════════════════ # 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 : - 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) ; - version : target value wins over the global value ; - payload, upload : taken from the target when present ; - kb4 / anypkg placeholders : ignored by the engine but preserved as-is, both at root level and at target level. 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. A meta-package (type: meta) has no targets : it is returned as-is (with a derived codename when missing) for Install-GenericPackage to process. .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 = '' ) 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 } if (-not $raw -or -not $raw.name) { Write-Fatal "Invalid pkgdef : 'name' is missing" } # ─── Meta-package : no target of its own ───────────────────────────── if ($raw.type -eq 'meta') { $resolved = @{} foreach ($key in $raw.Keys) { $resolved[$key] = $raw[$key] } if (-not $resolved.codename) { $resolved.codename = ($resolved.name.ToLower() -replace '\s+', '-') } Write-Devel "Resolved meta-package '$($resolved.name)'" return $resolved } $targets = @($raw.targets) 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 = @{ 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) 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 } # Placeholders : preserved as-is (root and target level) foreach ($ph in @('kb4', 'anypkg')) { if ($raw.$ph) { $resolved.$ph = $raw.$ph } if ($target.$ph) { $resolved.target.$ph = $target.$ph } } Write-Devel "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 ($raw.type -eq 'meta') { Write-Fatal "Expand-GenericPackageTargets does not support meta-packages" } 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 : - uniqueness of target ids ; - validity of asset / signature-file regex patterns ; - provider coherence (owner/repo, url, artifact) ; - provider 'url' does not support version 'latest' ; - declared payload vs asset pattern extension mismatch (warning) ; - upload coherence (nexus requires repository) ; - members resolution (files must exist, relative to the definition file). 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 } if (-not $raw -or -not $raw.name) { return [PSCustomObject]@{ Valid = $false; Errors = @("'name' is missing") ; Warnings = @() } } # ─── meta-package ───────────────────────────────────────────────────── if ($raw.type -eq 'meta') { if (-not @($raw.members).Count) { $errors += "meta-package requires at least one member" } if ($raw.targets) { $errors += "meta-package cannot have targets" } foreach ($member in @($raw.members)) { if ($Path -and -not (Test-FileExist (Join-Path (Split-Path $Path -Parent) $member))) { $errors += "member file not found : $member" } } return [PSCustomObject]@{ Valid = (-not $errors.Count); Errors = $errors; Warnings = $warnings } } $targets = @($raw.targets) 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'" } } } # provider url does not support 'latest' $version = $target.version ?? $raw.version ?? 'latest' if ($provider -eq 'url' -and $version -eq 'latest') { $errors += "[$($target.id)] provider 'url' does not support version 'latest' : pin a version" } } # 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'" } } } } # 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 } } # ═══════════════════════════════════════════════════════════════════════════════ # 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, mkdir, rename, unzip. All paths are relative to -DestinationPath. 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 switch ($step.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' { $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 } '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 → download → verification → install → post → upload # ═══════════════════════════════════════════════════════════════════════════════ <# .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 2. Download : download the asset (+ digest / signature verification) 3. Install : execute the install steps 4. Post : execute the post-install steps 5. Upload : publish the asset when the target declares an upload block For a meta-package, the members are installed first (same target selection), then the meta's own install/post steps run. Returns a result hashtable : Status (Success/Failed), Phase, Error, 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 = @{} ) 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 } # ─── Meta-package : process members first, then own steps ──────────── if ($raw.type -eq 'meta') { $result = @{ Status = '' Phase = '' Error = '' Name = $raw.name Codename = $raw.codename ?? (($raw.name.ToLower() -replace '\s+', '-')) Version = $raw.version ?? '' Target = $TargetId ?? "$Os/$Distro/$Arch" Asset = '' } foreach ($member in @($raw.members)) { if (-not [IO.Path]::IsPathRooted($member)) { if (-not $Path) { Write-Fatal "Relative meta member '$member' cannot be resolved : use -Path (relative members are resolved against the definition file location)" } $memberPath = Join-Path (Split-Path $Path -Parent) $member } else { $memberPath = $member } Write-Verbose "Meta-member : $memberPath" $memberParams = @{ Path = $memberPath DestinationPath = $DestinationPath CachePath = $CachePath mustacheValues = $mustacheValues } if ($TargetId) { $memberParams.TargetId = $TargetId } else { $memberParams.Os = $Os $memberParams.Distro = $Distro $memberParams.Arch = $Arch } $memberResult = Install-GenericPackage @memberParams if ($memberResult.Status -ne 'Success') { $result.Status = 'Failed' $result.Phase = 'Member' $result.Error = "member '$member' failed : $($memberResult.Error)" return $result } } $mv = @{} foreach ($k in $mustacheValues.Keys) { $mv[$k] = $mustacheValues[$k] } $mv.NAME = $raw.name $mv.CODENAME = $result.Codename $mv.VERSION = $result.Version $mv.OS = $Os $mv.DISTRO = $Distro $mv.ARCH = $Arch $mv.DEST = $DestinationPath $rc = Execute-GenericPackageSteps -Phase 'install' -Steps @($raw.install) -DestinationPath $DestinationPath -mustacheValues $mv if ($rc) { $rc = Execute-GenericPackageSteps -Phase 'post' -Steps @($raw.post) -DestinationPath $DestinationPath -mustacheValues $mv } if (-not $rc) { $result.Status = 'Failed' $result.Phase = 'Install' $result.Error = 'meta install/post steps failed' return $result } $result.Status = 'Success' return $result } # ─── Product : full pipeline ────────────────────────────────────────── $result = @{ Status = '' Phase = '' Error = '' 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 { $resolved = Resolve-GenericPackageDefinition -Definition $raw @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 # 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 : Download (+ verification) ────────────────────────── Write-Verbose "Phase 2 : 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 Write-Verbose "Downloaded asset : $asset" # ─── Phase 3 : Install ──────────────────────────────────────────── Write-Verbose "Phase 3 : 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 4 : Post ─────────────────────────────────────────────── Write-Verbose "Phase 4 : 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 5 : Upload ───────────────────────────────────────────── if ($resolved.upload) { Write-Verbose "Phase 5 : 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 } } $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.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 = @{} ) 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'" for ($i = 0; $i -lt $Names.Count; $i++) { if ($Names[$i] -match $pattern) { return @{ Index = $i; Spec = $spec } } } } return $null } End { Write-LeaveFunction } } <# .SYNOPSIS Verify a downloaded asset against a signature (checksum) file. .DESCRIPTION The signature file is expected to contain the hex hash, optionally followed by the file name (sha256sum style : '<hash> <filename>' or bare '<hash>'). #> 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 } } # ─── 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', 'Download-GenericPackageAsset', 'Get-GenericPackageAssetInfo', 'Install-GenericPackage' ) |