support/Epp.Packages.ps1
|
function Get-EppBundle { param([string] $AssetDirectory) $path = Join-Path $AssetDirectory 'bundle.json' if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'The Epp.Setup deployment bundle is missing. Install a packaged module, or build it with package-setup.ps1 before importing. No setup assets will be downloaded.' } $bundle = Read-EppJson $path $manifest = Import-PowerShellDataFile (Join-Path $AssetDirectory 'Epp.Setup.psd1') if ($bundle['schemaVersion'] -ne 1 -or $bundle['moduleVersion'] -cne $manifest.ModuleVersion -or $bundle['sourceRevision'] -cnotmatch '^[0-9a-f]{40}$' -or $bundle['sourceModified'] -isnot [bool] -or $bundle['files'] -isnot [Collections.IDictionary]) { throw 'Invalid or mismatched Epp.Setup bundle metadata. Reinstall the complete module version.' } foreach ($required in @('infra/main.json', 'providers/catalog.json', 'packages/catalog.json')) { if (-not $bundle.files.Contains($required)) { throw "The module bundle is missing '$required'." } } foreach ($file in $bundle.files.GetEnumerator()) { if ($file.Key -cnotmatch '^(infra|providers|packages)/[A-Za-z0-9][A-Za-z0-9_.-]*$' -or $file.Value -cnotmatch '^[0-9a-f]{64}$') { throw 'The module bundle contains an invalid asset path or SHA-256.' } $asset = Join-Path $AssetDirectory $file.Key if (-not (Test-Path -LiteralPath $asset -PathType Leaf)) { throw "The module bundle is missing '$($file.Key)'." } if ((Get-FileHash -LiteralPath $asset -Algorithm SHA256).Hash -ine $file.Value) { throw "The bundled asset '$($file.Key)' does not match its SHA-256. Reinstall the complete module version." } } $providers = Read-EppJson (Join-Path $AssetDirectory 'providers\catalog.json') $referencedAssets = @($providers.providers | ForEach-Object { "providers/$($_.file)" }) foreach ($asset in $referencedAssets) { if (-not $bundle.files.Contains($asset)) { throw "The module bundle has no checksum for '$asset'." } } return $bundle } function Get-EppLanguage { param( [string] $AssetDirectory, [string] $Language, [string] $PackageReleaseTag = 'latest', [switch] $NonInteractive ) $catalog = Read-EppJson (Join-Path $AssetDirectory 'packages/catalog.json') if ($catalog['schemaVersion'] -ne 4 -or @($catalog['packages']).Count -ne 3 -or [string]$catalog['repository'] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$' -or [string]$catalog['releaseTagPrefix'] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9.-]*-$') { throw 'Unsupported or empty language package catalog.' } $tagPattern = '^' + [regex]::Escape($catalog['releaseTagPrefix']) + '[0-9]+-[0-9]+$' $PackageReleaseTag = ([string]$PackageReleaseTag).Trim() if ($PackageReleaseTag -ieq 'latest') { $PackageReleaseTag = 'latest' } elseif ($PackageReleaseTag -cnotmatch $tagPattern) { throw "Invalid -PackageReleaseTag. Use latest or a tag matching '$($catalog['releaseTagPrefix'])<run>-<attempt>'." } $strategies = @{ javascript = 'ready'; dotnet = 'dotnet-publish'; python = 'remote-build' } $seen = @{} $assets = @{} $entries = @($catalog['packages']) foreach ($entry in $entries) { if ($entry -isnot [Collections.IDictionary] -or -not $strategies.ContainsKey([string]$entry['id']) -or $seen.ContainsKey($entry['id']) -or $entry['buildStrategy'] -cne $strategies[$entry['id']] -or -not $entry['displayName'] -or $entry['displayName'] -match '[\x00-\x1f]' -or [string]$entry['assetName'] -cnotmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*\.zip$' -or $assets.ContainsKey($entry['assetName'])) { throw 'Language catalog contains an invalid, unsupported, or duplicate entry.' } $seen[$entry['id']] = $true $assets[$entry['assetName']] = $true } $entry = Select-EppOption -Entries $entries -Name Language -PromptName Platform ` -Value $Language -NonInteractive:$NonInteractive $assetName = [string]$entry['assetName'] return [pscustomobject]@{ Id = $entry['id']; DisplayName = $entry['displayName']; AssetName = $assetName Repository = $catalog['repository']; ReleaseTag = $PackageReleaseTag; ReleaseTagPattern = $tagPattern BuildStrategy = $entry['buildStrategy'] } } function Get-EppPackageSource { param($Selection) $releaseBase = "https://github.com/$($Selection.Repository)/releases" $tag = $Selection.ReleaseTag if ($tag -eq 'latest') { # Resolve the public web redirect once, so the ZIP and checksums cannot select different releases. $response = Invoke-WebRequest -Uri "$releaseBase/latest" -Method Head -MaximumRedirection 5 -TimeoutSec 60 $resolved = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri $match = [regex]::Match($resolved, '^' + [regex]::Escape("$releaseBase/tag/") + '([^/?#]+)$') if (-not $match.Success) { throw 'GitHub latest did not resolve to a release in the configured package repository.' } $tag = [Uri]::UnescapeDataString($match.Groups[1].Value) } if ($tag -cnotmatch $Selection.ReleaseTagPattern) { throw "GitHub release '$tag' is not a supported EPP package release. Specify a reviewed -PackageReleaseTag." } return [pscustomobject]@{ ReleaseTag = $tag Url = "$releaseBase/download/$tag/$($Selection.AssetName)" ChecksumsUrl = "$releaseBase/download/$tag/SHA256SUMS.txt" } } function Get-EppPublishedChecksum { param([string] $Text, [string] $FileName) $hashes = @() foreach ($line in ($Text.TrimStart([char]0xfeff) -split '\r?\n')) { $match = [regex]::Match($line, '^([0-9a-fA-F]{64})[ \t]+\*?(.+?)[ \t]*$') if ($match.Success -and $match.Groups[2].Value -ceq $FileName) { $hashes += $match.Groups[1].Value.ToLowerInvariant() } } if ($hashes.Count -ne 1) { throw "The release checksum file must contain exactly one SHA-256 entry for '$FileName'." } return $hashes[0] } function Assert-EppArchive { param( [string] $Path, [ValidateSet('javascript', 'dotnet', 'python')][string] $Language, [ValidateSet('source', 'ready')][string] $Kind ) $archive = [IO.Compression.ZipFile]::OpenRead($Path) try { $names = @($archive.Entries | ForEach-Object FullName) $required = @('host.json') switch ($Language) { 'javascript' { $required += 'package.json' } 'dotnet' { if ($Kind -eq 'source') { $required += 'dotnet.csproj' } else { $required += @('worker.config.json', 'functions.metadata') } } 'python' { $required += @('function_app.py', 'requirements.txt') if ($Kind -eq 'ready') { $required += '.python_packages/lib/site-packages/azure/functions/__init__.py' } } } foreach ($file in $required) { if (@($names | Where-Object { $_ -ceq $file }).Count -ne 1) { throw "The $Language $Kind ZIP must contain exactly one '$file' at its required deployment path." } } if ($Language -eq 'dotnet' -and $Kind -eq 'ready' -and -not @($names | Where-Object { $_ -cmatch '^[^/]+\.dll$' }).Count) { throw 'The .NET publish output contains no application assemblies.' } if (@($names | Where-Object { $_ -match '\\|(^|/)\.\.?(/|$)|^/|^[a-zA-Z]:' }).Count) { throw 'The Function ZIP contains an absolute or traversing archive path.' } foreach ($entry in $archive.Entries) { if ($entry.FullName -match '(?i)(^|/)(local\.settings[^/]*\.json|\.env(?:\.[^/]*)?|[^/]+\.(pfx|p12|pem|key))$') { # Python's certifi dependency ships public CA roots, not an application private key. if ($Language -eq 'python' -and $Kind -eq 'ready' -and $entry.FullName -ceq '.python_packages/lib/site-packages/certifi/cacert.pem') { $reader = [IO.StreamReader]::new($entry.Open()) try { if ($reader.ReadToEnd() -match '-----BEGIN [^-]*PRIVATE KEY-----') { throw 'The CA bundle contains a private key.' } } finally { $reader.Dispose() } continue } throw 'The Function ZIP contains local settings or key material. Do not deploy this package.' } } } finally { $archive.Dispose() } } function Invoke-EppDotNet { param([Parameter(ValueFromRemainingArguments)][string[]] $Arguments) $PSNativeCommandUseErrorActionPreference = $false $output = & dotnet @Arguments 2>&1 if ($LASTEXITCODE -ne 0) { throw "dotnet $($Arguments[0]) failed (exit $LASTEXITCODE):`n$($output -join "`n")" } return $output -join "`n" } function Build-EppDotNetPackage { param([string] $SourcePath, [string] $Directory) if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { throw 'The .NET language needs the .NET 8 SDK on this computer. Install it once; setup performs the build automatically.' } $versions = @((Invoke-EppDotNet --list-sdks) -split '\r?\n' | ForEach-Object { if ($_ -match '^(8\.\d+\.\d+)\s') { [Version]$Matches[1] } } | Sort-Object -Descending) if (-not $versions.Count) { throw 'Install the .NET 8 SDK before deploying the .NET language. No Azure resources were changed.' } $sourceDirectory = Join-Path $Directory 'dotnet-source' $publishDirectory = Join-Path $Directory 'dotnet-publish' [IO.Compression.ZipFile]::ExtractToDirectory($SourcePath, $sourceDirectory) @{ sdk = @{ version = $versions[0].ToString(); rollForward = 'latestPatch' } } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $sourceDirectory 'global.json') -Encoding utf8NoBOM Write-Host 'Building .NET 8 for Linux automatically...' -ForegroundColor Cyan Push-Location -LiteralPath $sourceDirectory try { Invoke-EppDotNet -Arguments @('publish', 'dotnet.csproj', '--configuration', 'Release', '--runtime', 'linux-x64', '--self-contained', 'false', '-p:UseAppHost=false', '--output', $publishDirectory, '--nologo') | Out-Null } finally { Pop-Location } $path = Join-Path $Directory 'dotnet-ready.zip' [IO.Compression.ZipFile]::CreateFromDirectory($publishDirectory, $path) Assert-EppArchive -Path $path -Language dotnet -Kind ready return $path } function Get-EppPackage { param($Selection, [string] $Directory) $source = Get-EppPackageSource -Selection $Selection Write-Host "Downloading $($Selection.DisplayName) from release $($source.ReleaseTag) and verifying its SHA-256..." -ForegroundColor Cyan $checksumPath = Join-Path $Directory 'SHA256SUMS.txt' Invoke-WebRequest -Uri $source.ChecksumsUrl -OutFile $checksumPath -TimeoutSec 60 -MaximumRedirection 5 if ((Get-Item -LiteralPath $checksumPath).Length -gt 1MB) { throw 'The release checksum file is unexpectedly large.' } $expected = Get-EppPublishedChecksum -Text (Get-Content -LiteralPath $checksumPath -Raw -Encoding utf8) -FileName $Selection.AssetName $sourcePath = Join-Path $Directory $Selection.AssetName Invoke-WebRequest -Uri $source.Url -OutFile $sourcePath -TimeoutSec 300 -MaximumRedirection 5 if ((Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash -ine $expected) { throw 'The downloaded Function ZIP does not match its published SHA-256. No Azure resources were changed.' } $kind = if ($Selection.BuildStrategy -eq 'ready') { 'ready' } else { 'source' } Assert-EppArchive -Path $sourcePath -Language $Selection.Id -Kind $kind $path = if ($Selection.BuildStrategy -eq 'dotnet-publish') { Build-EppDotNetPackage -SourcePath $sourcePath -Directory $Directory } else { $sourcePath } return [pscustomobject]@{ Path = $path SourceSha256 = $expected Sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() RequiresRemoteBuild = $Selection.BuildStrategy -eq 'remote-build' Url = $source.Url; ReleaseTag = $source.ReleaseTag } } |