Airpower.psm1
Add-Type -AssemblyName System.Net.Http function HttpRequest { param ( [Parameter(Mandatory)] [string]$URL, [ValidateSet('GET', 'HEAD')] [string]$Method = 'GET', [string]$AuthToken, [string]$Accept, [string]$Range ) $req = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $URL) if ($AuthToken) { $req.Headers.Authorization = "Bearer $AuthToken" } if ($Accept) { $req.Headers.Accept.Add($Accept) } if ($Range) { $req.Headers.Range = $Range } return $req } function HttpSend { param( [Parameter(Mandatory, ValueFromPipeline)] [Net.Http.HttpRequestMessage]$Req ) $ch = [Net.Http.HttpClientHandler]::new() $ch.UseProxy = $false $cli = [Net.Http.HttpClient]::new($ch) $resp = $cli.SendAsync($Req, [Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() return $resp } function GetJsonResponse { param ( [Parameter(Mandatory, ValueFromPipeline)] [Net.Http.HttpResponseMessage]$Resp ) if (($resp.Content.Headers.ContentType.MediaType -ne 'application/json') -and -not $resp.Content.Headers.ContentType.MediaType.EndsWith('+json')) { throw "want application/json, got $($resp.Content.Headers.ContentType.MediaType)" } return $Resp.Content.ReadAsStringAsync().GetAwaiter().GetResult() | ConvertFrom-Json } function GetStringResponse { param ( [Parameter(Mandatory, ValueFromPipeline)] [Net.Http.HttpResponseMessage]$Resp ) return $Resp.Content.ReadAsStringAsync().GetAwaiter().GetResult() } function ConvertTo-HashTable { param ( [Parameter(ValueFromPipeline)] [PSCustomObject]$Object ) $Table = @{} $Object.PSObject.Properties | ForEach-Object { $V = $_.Value if ($V -is [Array]) { $V = [System.Collections.ArrayList]$V } elseif ($V -is [PSCustomObject]) { $V = ($V | ConvertTo-HashTable) } $Table.($_.Name) = $V } return $Table } function GetAirpowerPath { if ($AirpowerPath) { $AirpowerPath } elseif ($env:AirpowerPath) { $env:AirpowerPath } else { "$env:LocalAppData\Airpower" } } function GetPwrPullPolicy { if ($PwrPullPolicy) { $PwrPullPolicy } elseif ($env:PwrPullPolicy) { $env:PwrPullPolicy } else { "IfNotPresent" } } function GetPwrDBPath { "$(GetAirpowerPath)\db" } function GetPwrTempPath { "$(GetAirpowerPath)\temp" } function GetPwrContentPath { "$(GetAirpowerPath)\content" } function ResolvePackagePath { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Digest ) return "$(GetPwrContentPath)\$($digest.Substring('sha256:'.Length).Substring(0, 12))" } function MakeDirIfNotExist { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Path ) if (-not (Test-Path $Path -PathType Container)) { New-Item -Path $Path -ItemType Directory } } function OutPwrDB { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$PwrDB ) MakeDirIfNotExist (GetAirpowerPath) $PwrDB | ConvertTo-Json -Compress -Depth 10 | Out-File -FilePath (GetPwrDBPath) -Encoding 'UTF8' -Force } function GetPwrDB { $db = GetPwrDBPath if (Test-Path $db -PathType Leaf) { Get-Content $db -Raw | ConvertFrom-Json | ConvertTo-HashTable } else { @{ 'pkgdb' = @{} 'metadatadb' = @{} } } } function FindConfig { $path = (Get-Location).Path while ($path -ne '') { $cfg = "$path\Airpower.ps1" if (Test-Path $cfg -PathType Leaf) { return $cfg } $path = $path | Split-Path -Parent } } # U+2588 ? Full block # U+258C ? Left half block function GetUnicodeBlock { param ( [Parameter(Mandatory)] [int]$Index ) @{ 0 = " " 1 = "$([char]0x258c)" 2 = "$([char]0x2588)" }[$Index] } function GetProgress { param ( [Parameter(Mandatory)] [long]$Current, [Parameter(Mandatory)] [long]$Total ) $width = 30 $esc = [char]27 $p = $Current / $Total $inc = 1 / $width $full = [int][Math]::Floor($p / $inc) $left = [int][Math]::Floor((($p - ($inc * $full)) / $inc) * 2) $line = "$esc[94m$esc[47m" + ((GetUnicodeBlock 2) * $full) if ($full -lt $width) { $line += (GetUnicodeBlock $left) + (" " * ($width - $full - 1)) } $stat = '{0,10} / {1,-10}' -f ($Current | AsByteString -FixDecimals), ($Total | AsByteString) $line += "$esc[0m $stat" return "$line$esc[0m" } function WritePeriodicConsole { param ( [Parameter(Mandatory, ValueFromPipeline)] [scriptblock]$DeferLine ) if (($null -eq $lastwrite) -or (((Get-Date) - $lastwrite).TotalMilliseconds -gt 125)) { $line = & $DeferLine [Console]::Write("`r$line") $script:lastwrite = (Get-Date) } } function WriteConsole { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Line ) [Console]::Write("`r$Line") } function AsByteString { param ( [Parameter(Mandatory, ValueFromPipeline)] [long]$Bytes, [switch]$FixDecimals ) $n = [Math]::Abs($Bytes) $p = 0 while ($n -gt 1024) { $n /= 1024 $p += 3 } $r = @{ 0 = '' 3 = 'k' 6 = 'M' 9 = 'G' } return "{0:0.$(if ($FixDecimals) { '00' } else { '##' })} {1}B" -f $n, $r[[Math]::Min(9, $p)] } class Size : IComparable { [long]$Bytes hidden [string]$ByteString Size([long]$Bytes, [string]$ByteString) { $this.Bytes = $Bytes $this.ByteString = $ByteString } [int] CompareTo([object]$Obj) { return $this.Bytes.CompareTo($Obj.Bytes) } [string] ToString() { return $this.ByteString } } function AsSize { param ( [Parameter(Mandatory, ValueFromPipeline)] [long]$Bytes ) return [Size]::new($Bytes, ($Bytes | AsByteString)) } function FromOctalString { param ( [Parameter(ValueFromPipeline)] [string]$ASCII ) if (-not $ASCII) { return $null } return [Convert]::ToInt64($ASCII, 8) } function ParseTarHeader { param ( [Parameter(Mandatory)] [byte[]]$Buffer ) return @{ Filename = [Text.Encoding]::ASCII.GetString($Buffer[0..99]).Trim(0) Mode = [Text.Encoding]::ASCII.GetString($Buffer[100..107]).Trim(0) | FromOctalString OwnerID = [Text.Encoding]::ASCII.GetString($Buffer[108..115]).Trim(0) | FromOctalString GroupID = [Text.Encoding]::ASCII.GetString($Buffer[116..123]).Trim(0) | FromOctalString Size = [Text.Encoding]::ASCII.GetString($Buffer[124..135]).Trim(0) | FromOctalString Modified = [Text.Encoding]::ASCII.GetString($Buffer[136..147]).Trim(0) | FromOctalString Checksum = [Text.Encoding]::ASCII.GetString($Buffer[148..155]) Type = [Text.Encoding]::ASCII.GetString($Buffer[156..156]).Trim(0) Link = [Text.Encoding]::ASCII.GetString($Buffer[157..256]).Trim(0) UStar = [Text.Encoding]::ASCII.GetString($Buffer[257..262]).Trim(0) UStarVersion = [Text.Encoding]::ASCII.GetString($Buffer[263..264]).Trim(0) Owner = [Text.Encoding]::ASCII.GetString($Buffer[265..296]).Trim(0) Group = [Text.Encoding]::ASCII.GetString($Buffer[297..328]).Trim(0) DeviceMajor = [Text.Encoding]::ASCII.GetString($Buffer[329..336]).Trim(0) DeviceMinor = [Text.Encoding]::ASCII.GetString($Buffer[337..344]).Trim(0) FilenamePrefix = [Text.Encoding]::ASCII.GetString($Buffer[345..499]).Trim(0) } } function ParsePaxHeader { param ( [Parameter(Mandatory)] [IO.Compression.GZipStream]$Source, [Parameter(Mandatory)] [Collections.Hashtable]$Header ) $buf = New-Object byte[] $Header.Size [Util]::GzipRead($Source, $buf, $Header.Size) $content = [Text.Encoding]::UTF8.GetString($buf) $xhdr = @{} foreach ($line in $content -split "`n") { if ($line -match '([0-9]+) ([^=]+)=(.+)') { $xhdr += @{ "$($Matches[2])" = $Matches[3] } } } return $xhdr } function ExtractTarGz { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Path, [Parameter(Mandatory)] [string]$Digest ) $tgz = $Path | Split-Path -Leaf $layer = $tgz.Replace('.tar.gz', '') if ($layer -ne (Get-FileHash $Path).Hash) { [IO.File]::Delete($Path) throw "removed $Path because it had corrupted data" } $fs = [IO.File]::OpenRead($Path) try { $gz = [IO.Compression.GZipStream]::new($fs, [IO.Compression.CompressionMode]::Decompress, $true) try { $gz | ExtractTar -Digest $Digest } finally { $gz.Dispose() } } finally { $fs.Dispose() } return $Path } class Util { static [int] GzipRead([IO.Compression.GZipStream]$Source, [byte[]]$Buffer, [int]$Size) { $read = 0 while ($true) { $n = $Source.Read($buffer, $read, $Size - $read) $read += $n if ($n -eq 0) { break } elseif ($read -lt $size) { break } } return $read } } function ExtractTar { param ( [Parameter(Mandatory, ValueFromPipeline)] [IO.Compression.GZipStream]$Source, [Parameter(Mandatory)] [string]$Digest ) $root = ResolvePackagePath -Digest $Digest MakeDirIfNotExist -Path $root | Out-Null $buffer = New-Object byte[] 512 try { while ($true) { { $layer.Substring(0, 12) + ': Extracting ' + (GetProgress -Current $Source.BaseStream.Position -Total $Source.BaseStream.Length) + ' ' } | WritePeriodicConsole if ([Util]::GzipRead($Source, $buffer, 512) -eq 0) { break } $hdr = ParseTarHeader $buffer $size = if ($xhdr.Size) { $xhdr.Size } else { $hdr.Size } $filename = if ($xhdr.Path) { $xhdr.Path } else { $hdr.Filename } $file = ($filename -split '/' | Select-Object -Skip 1) -join '\' if ($filename.Contains('\..')) { throw "suspicious tar filename '$($filename)'" } if ($hdr.Type -eq [char]53 -and $file -ne '') { New-Item -Path "\\?\$root\$file" -ItemType Directory -Force -ErrorAction Ignore | Out-Null } if ($hdr.Type -in [char]103, [char]120) { $xhdr = ParsePaxHeader -Source $Source -Header $hdr } elseif ($hdr.Type -in [char]0, [char]48, [char]55 -and $filename.StartsWith('Files')) { $buf = New-Object byte[] $size [Util]::GzipRead($Source, $buf, $size) $fs = [IO.File]::Open("\\?\$root\$file", [IO.FileMode]::Create, [IO.FileAccess]::Write) try { if ($write) { $write.Wait() if ($write.IsFaulted) { throw $write.Exception } $writefs.Dispose() } } catch { $fs.Dispose() throw } $writefs = $fs $write = $writefs.WriteAsync($buf, 0, $size) $xhdr = $null } else { if ($size -gt 0) { [Util]::GzipRead($Source, (New-Object byte[] $size), $size) } $xhdr = $null } $leftover = $size % 512 if ($leftover -gt 0) { [Util]::GzipRead($Source, $buffer, (512 - $leftover)) } } if ($write) { $write.Wait() if ($write.IsFaulted) { throw $write.Exception } } } finally { if ($writefs) { $writefs.Dispose() } } $layer.Substring(0, 12) + ': Extracting ' + (GetProgress -Current $Source.BaseStream.Length -Total $Source.BaseStream.Length) + ' ' | WriteConsole } function GetDockerRepo { return 'airpower/shipyard' } function GetAuthToken { $auth = "https://auth.docker.io/token?service=registry.docker.io&scope=repository:$(GetDockerRepo):pull" $resp = HttpRequest $auth | HttpSend | GetJsonResponse return $resp.Token } function GetTagsList { $api = "/v2/$(GetDockerRepo)/tags/list" $endpoint = "https://index.docker.io$api" return HttpRequest $endpoint -AuthToken (GetAuthToken) | HttpSend | GetJsonResponse } function GetManifest { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Ref ) $api = "/v2/$(GetDockerRepo)/manifests/$Ref" $params = @{ URL = "https://index.docker.io$api" AuthToken = (GetAuthToken) Accept = 'application/vnd.docker.distribution.manifest.v2+json' } return HttpRequest @params | HttpSend } function GetBlob { param ( [Parameter(Mandatory)] [string]$Ref, [long]$StartByte ) $api = "/v2/$(GetDockerRepo)/blobs/$Ref" $params = @{ URL = "https://index.docker.io$api" AuthToken = (GetAuthToken) Accept = 'application/octet-stream' Range = "bytes=$StartByte-$($StartByte + 536870911)" # Request in 512 MB chunks } return HttpRequest @params | HttpSend } function GetDigestForRef { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Ref ) $api = "/v2/$(GetDockerRepo)/manifests/$Ref" $params = @{ URL = "https://index.docker.io$api" AuthToken = (GetAuthToken) Accept = 'application/vnd.docker.distribution.manifest.v2+json' Method = 'HEAD' } return HttpRequest @params | HttpSend | GetDigest } function GetDigest { param ( [Parameter(Mandatory, ValueFromPipeline)] [Net.Http.HttpResponseMessage]$Resp ) return $resp.Headers.GetValues('docker-content-digest') } function GetSize { param ( [Parameter(Mandatory, ValueFromPipeline)] [Net.Http.HttpResponseMessage]$Resp ) $manifest = $Resp | GetJsonResponse $size = 0 foreach ($layer in $manifest.layers) { if ($layer.mediaType -eq 'application/vnd.docker.image.rootfs.diff.tar.gzip') { $size += $layer.size } } return $size } function SaveBlob { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Digest ) $sha256 = $Digest.Substring('sha256:'.Length) $path = "$(GetPwrTempPath)\$sha256.tar.gz" MakeDirIfNotExist (GetPwrTempPath) | Out-Null $fs = [IO.File]::Open($path, [IO.FileMode]::OpenOrCreate) $fs.Seek(0, [IO.SeekOrigin]::End) | Out-Null try { do { $resp = GetBlob -Ref $Digest -StartByte $fs.Length $size = if ($resp.Content.Headers.ContentRange.HasLength) { $resp.Content.Headers.ContentRange.Length } else { $resp.Content.Headers.ContentLength + $fs.Length } $task = $resp.Content.CopyToAsync($fs) while (-not $task.IsCompleted) { $sha256.Substring(0, 12) + ': Downloading ' + (GetProgress -Current $fs.Length -Total $size) + ' ' | WriteConsole Start-Sleep -Milliseconds 125 } } while ($fs.Length -lt $size) $sha256.Substring(0, 12) + ': Downloading ' + (GetProgress -Current $fs.Length -Total $size) + ' ' | WriteConsole } finally { $fs.Close() } return $path } function WriteHost { param ( [string]$Line ) Write-Information $Line -InformationAction Continue } function AsRemotePackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$RegistryTag ) if ($RegistryTag -match '(.*)-([0-9].+)') { return @{ Package = $Matches[1] Tag = $Matches[2] | AsTagHashtable } } throw "failed to parse registry tag: $RegistryTag" } function AsTagHashtable { param ( [Parameter(ValueFromPipeline)] [string]$Tag ) if ($Tag -in 'latest', '', $null) { return @{ Latest = $true } } if ($Tag -match '^([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?(?:(?:\+|_)([0-9]+))?$') { return @{ Major = $Matches[1] Minor = $Matches[2] Patch = $Matches[3] Build = $Matches[4] } } throw "failed to parse tag: $Tag" } function AsTagString { param ( [Parameter(Mandatory, ValueFromPipeline)] [collections.Hashtable]$Tag ) if ($true -eq $Tag.Latest) { "latest" } else { $s = "$($Tag.Major)" if ($Tag.Minor) { $s += ".$($Tag.Minor)" } if ($Tag.Patch) { $s += ".$($Tag.Patch)" } if ($Tag.Build) { $s += "+$($Tag.Build)" } $s } } function GetRemotePackages { $remote = @{} foreach ($tag in (GetTagsList).Tags) { $pkg = $tag | AsRemotePackage $remote.$($pkg.Package) = $remote.$($pkg.Package) + @($pkg.Tag) } $remote } function GetRemoteTags { $remote = GetRemotePackages $o = New-Object PSObject foreach ($k in $remote.keys | Sort-Object) { $arr = @() foreach ($t in $remote.$k) { $arr += [Tag]::new(($t | AsTagString)) } $o | Add-Member -MemberType NoteProperty -Name $k -Value ($arr | Sort-Object -Descending) } $o } function AsPackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Pkg ) if ($Pkg -match '^([^:]+)(?::([^:]+))?(?:::?([^:]+))?$') { return @{ Package = $Matches[1] Tag = $Matches[2] | AsTagHashtable Config = if ($Matches[3]) { $Matches[3] } else { 'default' } } } throw "failed to parse package: $Pkg" } function ResolvePackageRefPath { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) return "$(GetAirpowerPath)\ref\$($Pkg.Package)$(if (-not $Pkg.Tag.Latest) { "-$($Pkg.Tag | AsTagString)" })" } function ResolveRemoteRef { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $remote = GetRemoteTags if (-not $remote.$($Pkg.Package)) { throw "no such package: $($Pkg.Package)" } $want = $Pkg.Tag foreach ($got in $remote.$($Pkg.Package)) { $eq = $true if ($null -ne $want.Major) { $eq = $eq -and $want.Major -eq $got.Major } if ($null -ne $want.Minor) { $eq = $eq -and $want.Minor -eq $got.Minor } if ($null -ne $want.Patch) { $eq = $eq -and $want.Patch -eq $got.Patch } if ($null -ne $want.Build) { $eq = $eq -and $want.Build -eq $got.Build } if ($eq) { return "$($Pkg.Package)-$(($got.ToString()).Replace('+', '_'))" } } throw "no such $($Pkg.Package) tag: $($Pkg.Tag)" } function GetLocalPackages { $db = GetPwrDB $pkgs = @() foreach ($pkg in $db.pkgdb.keys) { foreach ($tag in $db.pkgdb.$pkg.keys) { $t = [Tag]::new($tag) $digest = if ($t.None) { $tag } else { $db.pkgdb.$pkg.$tag } $pkgs += [PSCustomObject]@{ Package = $pkg Tag = $t Digest = $digest | AsDigest Size = $db.metadatadb.$digest.size | AsSize # Signers } } } if (-not $pkgs) { $pkgs = ,[PSCustomObject]@{ Package = $null Tag = $null Digest = $null Size = $null } } return $pkgs } function ResolvePackageDigest { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $db = GetPwrDB return $db.pkgdb.$($Pkg.Package).$($Pkg.Tag | AsTagString) } function InstallPackage { # $db, $status param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $db = GetPwrDB $digest = $Pkg.Digest $name = $Pkg.Package $tag = $Pkg.Tag | AsTagString if ($null -ne $db.pkgdb.$name.$tag -and $digest -ne $db.pkgdb.$name.$tag) { $status = 'newer' $old = $db.pkgdb.$name.$tag $db.pkgdb.$name.Remove($tag) $db.pkgdb.$name.$old = $null if ($db.metadatadb.$old.refcount -gt 0) { $db.metadatadb.$old.refcount -= 1 } } if ($null -eq $db.pkgdb.$name.$tag) { if ($db.metadatadb.$digest) { if ($db.pkgdb.$name.ContainsKey($digest)) { $db.pkgdb.$name.Remove($digest) } $status = 'tag' $db.metadatadb.$digest.refcount += 1 } else { $status = 'new' $db.metadatadb.$digest = @{ RefCount = 1 Size = $Pkg.Size } } $db.pkgdb.$name += @{ "$tag" = $digest } } else { $status = 'uptodate' } return $db, $status } function PullPackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $manifest = $Pkg | ResolveRemoteRef | GetManifest $Pkg.Digest = $manifest | GetDigest $Pkg.Size = $manifest | GetSize WriteHost "$($pkg.Tag | AsTagString): Pulling $($Pkg.Package)" WriteHost "Digest: $($Pkg.Digest)" $db, $status = $Pkg | InstallPackage $ref = "$($Pkg.Package):$($Pkg.Tag | AsTagString)" if ($status -eq 'uptodate') { WriteHost "Status: Package is up to date for $ref" } else { if ($status -eq 'new') { $manifest | SavePackage } $refpath = $Pkg | ResolvePackageRefPath MakeDirIfNotExist (Split-Path $refpath) | Out-Null if (Test-Path -Path $refpath -PathType Container) { [IO.Directory]::Delete($refpath) } New-Item $refpath -ItemType Junction -Target ($Pkg.Digest | ResolvePackagePath) | Out-Null WriteHost "Status: Downloaded newer package for $ref" $db | OutPwrDB } } function SavePackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [Net.Http.HttpResponseMessage]$Resp ) [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::CursorVisible = $false try { $manifest = $Resp | GetJsonResponse $digest = $Resp | GetDigest $temp = @() foreach ($layer in $manifest.layers) { if ($layer.mediaType -eq 'application/vnd.docker.image.rootfs.diff.tar.gzip') { try { $temp += $layer.Digest | SaveBlob | ExtractTarGz -Digest $digest "$($layer.Digest.Substring('sha256:'.Length).Substring(0, 12)): Pull complete" + ' ' * 60 | WriteConsole } finally { [Console]::WriteLine() } } } foreach ($tmp in $temp) { [IO.File]::Delete($tmp) } } finally { [Console]::CursorVisible = $true } } function UninstallPackage { # $db, $digest, $err param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $db = GetPwrDB $name = $Pkg.Package $key = $Pkg.Tag | AsTagString $table = $db.pkgdb.$name if (-not $db.pkgdb.ContainsKey($name) -or -not $table.ContainsKey($key)) { return $null, $null, "package not installed: ${name}:$key" } $digest = $table.$key $table.Remove($key) if ($table.Count -eq 0) { $db.pkgdb.Remove($name) } if ($db.metadatadb.$digest.refcount -gt 0) { $db.metadatadb.$digest.refcount -= 1 } if (0 -eq $db.metadatadb.$digest.refcount) { $db.metadatadb.Remove($digest) } else { $digest = $null } return $db, $digest, $null } function RemovePackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $db, $digest, $err = $Pkg | UninstallPackage if ($null -ne $err) { throw $err } WriteHost "Untagged: $($Pkg.Package):$($pkg.Tag | AsTagString)" if ($null -ne $digest) { $content = $digest | ResolvePackagePath if (Test-Path $content -PathType Container) { [IO.Directory]::Delete($content, $true) } WriteHost "Deleted: $digest" } $refpath = $Pkg | ResolvePackageRefPath if (Test-Path -Path $refpath -PathType Container) { [IO.Directory]::Delete($refpath) } $db | OutPwrDB } function UninstallOrhpanedPackages { $db = GetPwrDB $rm = @() foreach ($digest in $db.metadatadb.keys) { $tbl = $db.metadatadb.$digest if ($tbl.refcount -eq 0) { $tbl.digest = $digest $rm += ,$tbl } } $empty = @() foreach ($i in $rm) { $db.metadatadb.Remove($i.digest) foreach ($pkg in $db.pkgdb.keys) { if ($db.pkgdb.$pkg.ContainsKey($i.digest)) { $db.pkgdb.$pkg.Remove($i.digest) if ($db.pkgdb.$pkg.Count -eq 0) { $empty += $pkg } } } } foreach ($name in $empty) { $db.pkgdb.Remove($name) } return $db, $rm } function PrunePackages { $db, $pruned = UninstallOrhpanedPackages $bytes = 0 foreach ($i in $pruned) { $content = $i.Digest | ResolvePackagePath WriteHost "Deleted: $($i.Digest)" $stats = Get-ChildItem $content -Recurse | Measure-Object -Sum Length $bytes += $stats.Sum if (Test-Path $content -PathType Container) { [IO.Directory]::Delete("\\?\$((Resolve-Path $content).Path)", $true) } } WriteHost "Total reclaimed space: $($bytes | AsByteString)" $db | OutPwrDB } class Digest { [string]$Sha256 Digest([string]$sha256) { $this.Sha256 = $sha256 } [string] ToString() { return "$($this.Sha256.Substring('sha256:'.Length).Substring(0, 12))" } } function AsDigest { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Digest ) return [Digest]::new($Digest) } class Tag : IComparable { [object]$Major [object]$Minor [object]$Patch [object]$Build hidden [bool]$None hidden [bool]$Latest Tag([string]$tag) { if ($tag -eq '<none>' -or $tag.StartsWith('sha256:')) { $this.None = $true return } if ($tag -in 'latest', '') { $this.Latest = $true return } if ($tag -match '^([0-9]+)(?:\.([0-9]+))?(?:\.([0-9]+))?(?:(?:\+|_)([0-9]+))?$') { $this.Major = $Matches[1] $this.Minor = $Matches[2] $this.Patch = $Matches[3] $this.Build = $Matches[4] return } throw "failed to parse tag: $tag" } [int] CompareTo([object]$Obj) { if ($Obj -isnot $this.GetType()) { throw "cannot compare types $($Obj.GetType()) and $($this.GetType())" } if ($this.Latest -or $Obj.Latest) { return $this.Latest - $Obj.Latest } if ($this.None -or $Obj.None) { return $Obj.None - $this.None } if ($this.Major -ne $Obj.Major) { return $this.Major - $Obj.Major } elseif ($this.Minor -ne $Obj.Minor) { return $this.Minor - $Obj.Minor } elseif ($this.Patch -ne $Obj.Patch) { return $this.Patch - $Obj.Patch } else { return $this.Build - $Obj.Build } } [string] ToString() { if ($this.None) { return '' } if ($null -eq $this.Major) { return 'latest' } $s = "$($this.Major)" if ($this.Minor) { $s += ".$($this.Minor)" } if ($this.Patch) { $s += ".$($this.Patch)" } if ($this.Build) { $s += "+$($this.Build)" } return $s } } function ResolvePackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Ref ) $pkg = $Ref | AsPackage $digest = $pkg | ResolvePackageDigest switch (GetPwrPullPolicy) { 'IfNotPresent' { if (-not $digest) { $pkg | PullPackage | Out-Null $pkg.digest = $pkg | ResolvePackageDigest } } 'Never' { if (-not $digest) { throw "cannot find package $($pkg.Package):$($pkg.Tag | AsTagString)" } } 'Always' { $pkg | PullPackage | Out-Null $pkg.digest = $pkg | ResolvePackageDigest } default { throw "invalid PwrPullPolicy '$(GetPwrPullPolicy)'" } } return $pkg } function GetSessionState { return @{ Vars = (Get-Variable -Scope Global | ForEach-Object { ConvertTo-HashTable $_ } ) Env = (Get-Item Env:) } } function SaveSessionState { param ( [Parameter(Mandatory)] [string]$GUID ) Set-Variable -Name "AirpowerSaveState_$GUID" -Value (GetSessionState) -Scope Global } function ClearSessionState { param ( [Parameter(Mandatory)] [string]$GUID ) $default = "AirpowerSaveState_$GUID", '__LastHistoryId', '__VSCodeOriginalPrompt', '__VSCodeOriginalPSConsoleHostReadLine', '?', '^', '$', 'args', 'ConfirmPreference', 'DebugPreference', 'EnabledExperimentalFeatures', 'Error', 'ErrorActionPreference', 'ErrorView', 'ExecutionContext', 'false', 'FormatEnumerationLimit', 'HOME', 'Host', 'InformationPreference', 'input', 'IsCoreCLR', 'IsLinux', 'IsMacOS', 'IsWindows', 'MaximumHistoryCount', 'MyInvocation', 'NestedPromptLevel', 'null', 'OutputEncoding', 'PID', 'PROFILE', 'ProgressPreference', 'PSBoundParameters', 'PSCommandPath', 'PSCulture', 'PSDefaultParameterValues', 'PSEdition', 'PSEmailServer', 'PSHOME', 'PSScriptRoot', 'PSSessionApplicationName', 'PSSessionConfigurationName', 'PSSessionOption', 'PSStyle', 'PSUICulture', 'PSVersionTable', 'PWD', 'ShellId', 'StackTrace', 'true', 'VerbosePreference', 'WarningPreference', 'WhatIfPreference', 'ConsoleFileName', 'MaximumAliasCount', 'MaximumDriveCount', 'MaximumErrorCount', 'MaximumFunctionCount', 'MaximumVariableCount' foreach ($v in (Get-Variable -Scope Global)) { if ($v.name -notin $default) { Remove-Variable -Name $v.name -Scope Global -Force -ErrorAction SilentlyContinue } } foreach ($k in [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::User).keys) { if ($k -notin 'temp', 'tmp', 'AirpowerPath') { Remove-Item "env:$k" -Force -ErrorAction SilentlyContinue } } Remove-Item 'env:AirpowerLoadedPackages' -Force -ErrorAction SilentlyContinue } function RestoreSessionState { param ( [Parameter(Mandatory)] [string]$GUID ) $state = (Get-Variable "AirpowerSaveState_$GUID").value foreach ($v in $state.vars) { Set-Variable -Name $v.name -Value $v.value -Scope Global -Force -ErrorAction SilentlyContinue } foreach ($e in $state.env) { Set-Item -Path "env:$($e.name)" -Value $e.value -Force -ErrorAction SilentlyContinue } Remove-Variable "AirpowerSaveState_$GUID" -Force -Scope Global -ErrorAction SilentlyContinue } function GetPackageDefinition { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Digest ) if (-not $Digest) { return $null } $root = ResolvePackagePath -Digest $Digest return (Get-Content -Raw "$root\.pwr").Replace('${.}', $root.Replace('\', '\\')) | ConvertFrom-Json | ConvertTo-HashTable } function ConfigurePackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg, [switch]$AppendPath ) $defn = $Pkg.Digest | GetPackageDefinition $cfg = if ($Pkg.Config -eq 'default') { $defn } else { $defn.$($Pkg.Config) } if (-not $cfg) { throw "configuration '$($Pkg.Config)' not found for $($Pkg.Package):$($Pkg.Tag | AsTagString)" } foreach ($k in $cfg.env.keys) { if ($k -eq 'Path') { if ($AppendPath) { $post = "$(if ($env:Path -and -not $env:Path.StartsWith(';')) { ';' })$env:Path" } else { $pre = "$env:Path$(if ($env:Path) { ';' })" } } else { $pre = $post = '' } Set-Item "env:$k" "$pre$($cfg.env.$k)$post" } } function LoadPackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $digest = $Pkg | ResolvePackageDigest $ref = "$($Pkg.Package):$($Pkg.Tag | AsTagString)" if (-not $digest) { throw "no such package $ref" } $Pkg.Digest = $digest WriteHost "Digest: $digest" if ($digest -notin ($env:AirpowerLoadedPackages -split ';')) { $Pkg | ConfigurePackage $env:AirpowerLoadedPackages += "$(if ($env:AirpowerLoadedPackages) { ';' })$digest" WriteHost "Status: Session configured for $ref" } else { WriteHost "Status: Session is up to date for $ref" } } function ExecuteScript { param ( [Parameter(Mandatory)] [scriptblock]$ScriptBlock, [Parameter(Mandatory)] [Collections.Hashtable[]]$Pkgs ) $GUID = New-Guid SaveSessionState $GUID try { ClearSessionState $GUID $env:Path = '' foreach ($pkg in $Pkgs) { $pkg.digest = $pkg | ResolvePackageDigest $ref = "$($Pkg.Package):$($Pkg.Tag | AsTagString)" if (-not $pkg.digest) { throw "no such package $ref" } $pkg | ConfigurePackage -AppendPath } $env:Path = "$(if ($env:Path) { "$env:Path;" })$env:SYSTEMROOT;$env:SYSTEMROOT\System32;$PSHOME" & $ScriptBlock } finally { RestoreSessionState $GUID } } function Migrate { # TODO REMOVE if (Test-Path "$(GetAirpowerPath)\pwrdb" -PathType Leaf) { Rename-Item -Path "$(GetAirpowerPath)\pwrdb" -NewName (GetPwrDBPath) -Force *>$null } $env = [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::User) if ($env.PwrHome -and -not $env.AirpowerPath) { [Environment]::SetEnvironmentVariable('AirpowerPath', $env.PwrHome, [EnvironmentVariableTarget]::User) } if ($env:PwrHome) { $env:AirpowerPath = $env:PwrHome } } # TODO REMOVE function Invoke-Airpower { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateSet('version', 'v', 'remote', 'list', 'load', 'pull', 'exec', 'run', 'remove', 'rm', 'prune', 'help', 'h')] [string]$Command, [Parameter(ValueFromRemainingArguments)] [object[]]$ArgumentList ) Migrate # TODO REMOVE try { switch ($Command) { {$_ -in 'v', 'version'} { Invoke-AirpowerVersion } 'remote' { Invoke-AirpowerRemote @ArgumentList } 'list' { Invoke-AirpowerList } 'load' { if ($PSVersionTable.PSVersion.Major -le 5) { Invoke-AirpowerLoad @ArgumentList } else { Invoke-AirpowerLoad $ArgumentList } } 'pull' { if ($PSVersionTable.PSVersion.Major -le 5) { Invoke-AirpowerPull @ArgumentList } else { Invoke-AirpowerPull $ArgumentList } } 'prune' { Invoke-AirpowerPrune } {$_ -in 'remove', 'rm'} { if ($PSVersionTable.PSVersion.Major -le 5) { Invoke-AirpowerRemove @ArgumentList } else { Invoke-AirpowerRemove $ArgumentList } } 'exec' { $params, $remaining = ResolveParameters 'Invoke-AirpowerExec' $ArgumentList Invoke-AirpowerExec @params @remaining } 'run' { Invoke-AirpowerRun @ArgumentList } {$_ -in 'help', 'h'} { Invoke-AirpowerHelp } } } catch { Write-Error $_ } } function GetConfigPackages { $cfg = FindConfig if ($cfg) { . $cfg } [string[]]$AirpowerPackages } function ResolveParameters { param ( [Parameter(Mandatory)] [string]$FnName, [object[]]$ArgumentList ) $fn = Get-Item "function:$FnName" $params = @{} $remaining = [Collections.ArrayList]@() for ($i = 0; $i -lt $ArgumentList.Count; $i++) { if ($fn.parameters.keys -and ($ArgumentList[$i] -match '^-([^:]+)(?::(.*))?$') -and ($Matches[1] -in $fn.parameters.keys)) { $name = $Matches[1] $value = $Matches[2] if ($value) { $params.$name = $value } else { if ($fn.parameters.$name.SwitchParameter -and $null -eq $value) { $params.$name = $true } else { $params.$name = $ArgumentList[$i+1] $i++ } } } else { [void]$remaining.Add($ArgumentList[$i]) } } return $params, $remaining } function Invoke-AirpowerVersion { [CmdletBinding()] param () (Get-Module -Name Airpower).Version } function Invoke-AirpowerList { [CmdletBinding()] param () GetLocalPackages } function Invoke-AirpowerLoad { [CmdletBinding()] param ( [string[]]$Packages ) if (-not $Packages) { $Packages = GetConfigPackages } if (-not $Packages) { Write-Error 'no packages provided' } foreach ($p in $Packages) { $p | ResolvePackage | LoadPackage } } function Invoke-AirpowerRemove { [CmdletBinding()] param ( [string[]]$Packages ) foreach ($p in $Packages) { $p | AsPackage | RemovePackage } } function Invoke-AirpowerPrune { [CmdletBinding()] param () PrunePackages } function Invoke-AirpowerPull { [CmdletBinding()] param ( [string[]]$Packages ) if (-not $Packages) { $Packages = GetConfigPackages } if (-not $Packages) { Write-Error "no packages provided" } foreach ($p in $Packages) { $p | AsPackage | PullPackage } } function Invoke-AirpowerRun { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$FnName, [Parameter(ValueFromRemainingArguments)] [object[]]$ArgumentList ) $cfg = FindConfig if ($cfg) { . $cfg } $fn = Get-Item "function:Airpower$FnName" if ($fn) { $params, $remaining = ResolveParameters "Airpower$FnName" $ArgumentList $script = { & $fn @params @remaining } if ($AirpowerPackages) { Invoke-AirpowerExec -Packages $AirpowerPackages -ScriptBlock $script } else { & $script } } } function Invoke-AirpowerExec { [CmdletBinding()] param ( [string[]]$Packages, [scriptblock]$ScriptBlock = { $Host.EnterNestedPrompt() } ) if (-not $Packages) { $Packages = GetConfigPackages } if (-not $Packages) { Write-Error "no packages provided" } $resolved = @() foreach ($p in $Packages) { $resolved += $p | ResolvePackage } ExecuteScript -ScriptBlock $ScriptBlock -Pkgs $resolved } function Invoke-AirpowerRemote { [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateSet('list')] [string]$Command ) switch ($Command) { 'list' { GetRemoteTags } } } function Invoke-AirpowerHelp { @" Usage: airpower COMMAND A package manager and environment to provide consistent tooling for software teams Commands: version, v Outputs the version of the module list Outputs a list of installed packages remote list Outputs an object of remote packages and versions pull Downlaods packages load Loads packages into the PowerShell session exec Runs a user-defined scriptblock in a managed PowerShell session state run Runs a user-defined scriptblock provided in a project file prune Deletes unreferenced packages remove, rm Untags and deletes packages "@ } Set-Alias -Name 'airpower' -Value 'Invoke-Airpower' -Scope Global Set-Alias -Name 'air' -Value 'Invoke-Airpower' -Scope Global Set-Alias -Name 'pwr' -Value 'Invoke-Airpower' -Scope Global |