Airpower.psm1
Add-Type -AssemblyName System.Net.Http function HttpRequest { param ( [Parameter(Mandatory = $true)] [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 = $true, ValueFromPipeline = $true)] [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 = $true, ValueFromPipeline = $true)] [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 GetPwrHome { if ($PwrHome) { $PwrHome } elseif ($env:PwrHome) { $env:PwrHome } else { "$env:LocalAppData\Airpower" } } function GetPwrPullPolicy { if ($PwrPullPolicy) { $PwrPullPolicy } elseif ($env:PwrPullPolicy) { $env:PwrPullPolicy } else { "IfNotPresent" } } function GetPwrDBPath { "$(GetPwrHome)\pwrdb" } function GetPwrTempPath { "$(GetPwrHome)\temp" } function GetPwrContentPath { "$(GetPwrHome)\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 = $true, ValueFromPipeline = $true)] [Collections.Hashtable]$PwrDB ) MakeDirIfNotExist (GetPwrHome) $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 = Resolve-Path . while ($path -ne '') { $cfg = "$path\pwr.ps1" if (Test-Path $cfg -PathType Leaf) { return $cfg } $path = $path | Split-Path -Parent } } # U+2588 ? Full block # U+2589 ? Left seven eighths block # U+258A ? Left three quarters block # U+258B ? Left five eighths block # U+258C ? Left half block # U+258D ? Left three eighths block # U+258E ? Left one quarter block # U+258F ? Left one eighth block function GetUnicodeBlock { param ( [Parameter(Mandatory = $true)] [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 -ne $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 = $true, ValueFromPipeline = $true)] [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.Stream]$Source, [Parameter(Mandatory)] [Collections.Hashtable]$Header ) $buf = New-Object byte[] $Header.Size $Source.Read($buf, 0, $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 CopyToFile { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Digest', Justification='False Positive')] param ( [Parameter(Mandatory)] [IO.Stream]$Source, [Parameter(Mandatory)] [string]$FilePath, [Parameter(Mandatory)] [long]$Size, [Parameter(Mandatory)] [string]$Digest ) try { $copied = 0 $bufsize = 4096 $buf = New-Object byte[] $bufsize $fs = [IO.File]::Open("\\?\$FilePath", [IO.FileMode]::Create) $fs.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null while ($copied -lt $Size) { { $Digest.Substring(0,12) + ': Extracting ' + (GetProgress -Current $Stream.Position -Total $Stream.Length) + ' ' } | WritePeriodicConsole $amount = if (($Size - $copied) -gt $bufsize) { $bufsize } else { $Size - $copied } $Source.Read($buf, 0, $amount) | Out-Null $fs.Write($buf, 0, $amount) | Out-Null $copied += $amount } } finally { $fs.Dispose() } } function DecompressTarGz { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Path ) $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" } $tar = $Path.Replace('.tar.gz', '.tar') try { $stream = [IO.File]::Open($tar, [IO.FileMode]::OpenOrCreate) $stream.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null try { $fs = [IO.File]::OpenRead($Path) try { $gz = [IO.Compression.GZipStream]::new($fs, [IO.Compression.CompressionMode]::Decompress, $true) $cancel = [Threading.CancellationTokenSource]::new() $task = $gz.CopyToAsync($stream, $cancel.Token) while (-not $task.IsCompleted) { $layer.Substring(0,12) + ': Decompressing ' + (GetProgress -Current $fs.Position -Total $fs.Length) | WriteConsole Start-Sleep -Milliseconds 125 } } finally { $cancel.Cancel($true) $gz.Dispose() $cancel.Dispose() } } finally { [void]$fs.Seek(0, [IO.SeekOrigin]::Begin) $fs.Dispose() } } finally { $stream.Dispose() } return $tar } function ExtractTar { param ( [Parameter(Mandatory, ValueFromPipeline)] [string]$Path, [Parameter(Mandatory)] [string]$Digest ) try { $tar = $Path | Split-Path -Leaf $layer = $tar.Replace('.tar', '') $root = ResolvePackagePath -Digest $Digest MakeDirIfNotExist -Path $root | Out-Null $stream = [IO.File]::Open($Path, [IO.FileMode]::OpenOrCreate) $stream.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null $buffer = New-Object byte[] 512 while ($stream.Position -lt $stream.Length) { $stream.Read($buffer, 0, 512) | Out-Null $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 SilentlyContinue | Out-Null } if ($hdr.Type -in [char]103, [char]120) { $xhdr = ParsePaxHeader -Source $stream -Header $hdr } elseif ($hdr.Type -in [char]0, [char]48, [char]55 -and $filename.StartsWith('Files')) { CopyToFile -Source $stream -FilePath "$root\$file" -Size $size -Digest $layer $xhdr = $null } else { $stream.Seek($size, [IO.SeekOrigin]::Current) | Out-Null $xhdr = $null } $leftover = $size % 512 if ($leftover -gt 0) { $stream.Seek(512 - ($size % 512), [IO.SeekOrigin]::Current) | Out-Null } } } finally { $stream.Dispose() } } 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 = $true)] [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-" } return HttpRequest @params | HttpSend } function GetDigestForRef { param ( [Parameter( Mandatory = $true, ValueFromPipeline = $true)] [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 = $true, ValueFromPipeline = $true)] [Net.Http.HttpResponseMessage]$Resp ) return $resp.Headers.GetValues('docker-content-digest') } function GetSize { param ( [Parameter( Mandatory = $true, ValueFromPipeline = $true)] [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 try { $fs = [IO.File]::Open($path, [IO.FileMode]::OpenOrCreate) $fs.Seek(0, [IO.SeekOrigin]::End) | Out-Null $resp = GetBlob -Ref $Digest -StartByte $fs.Length $size = $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 } } finally { $fs.Close() } return $path } function WriteHost { param ( [string]$Line ) Write-Information $Line -InformationAction Continue } function AsRemotePackage { param ( [Parameter( Mandatory = $true, ValueFromPipeline = $true)] [string]$RegistryTag ) if ($RegistryTag -match '(.*)-([0-9].+)') { return @{ Package = $Matches[1] Tag = $Matches[2] | AsTag } } throw "failed to parse registry tag: $RegistryTag" } function AsTag { param ( [Parameter( ValueFromPipeline = $true)] [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 = $true, ValueFromPipeline = $true)] [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 = $true, ValueFromPipeline = $true)] [string]$Pkg ) if ($Pkg -match '^([^:]+)(?::([^:]+))?(?:::?([^:]+))?$') { return @{ Package = $Matches[1] Tag = $Matches[2] | AsTag Config = if ($Matches[3]) { $Matches[3] } else { 'default' } } } throw "failed to parse package: $Pkg" } function ResolveRemoteRef { param ( [Parameter( Mandatory = $true, ValueFromPipeline = $true)] [object]$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 } 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 $tmp = @() foreach ($layer in $manifest.layers) { if ($layer.mediaType -eq 'application/vnd.docker.image.rootfs.diff.tar.gzip') { try { $tar = $layer.Digest | SaveBlob | DecompressTarGz $tar | ExtractTar -Digest $digest "$($layer.Digest.Substring('sha256:'.Length).Substring(0, 12)): Pull complete" + ' ' * 60 | WriteConsole $tmp += $tar, "$tar.gz" } finally { [Console]::WriteLine() } } } foreach ($file in $tmp) { [IO.File]::Delete($file) } } 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" } $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)", $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 = $true, ValueFromPipeline = $true)] [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 { Set-Variable -Name 'PwrSaveState' -Value (GetSessionState) -Scope Global } function ClearSessionState { $default = '__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', 'PwrSaveState', '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', 'pwrhome') { Remove-Item "env:$k" -Force -ErrorAction SilentlyContinue } } Remove-Item 'env:PwrLoadedPackages' -Force -ErrorAction SilentlyContinue } function RestoreSessionState { foreach ($v in $PwrSaveState.vars) { Set-Variable -Name $v.name -Value $v.value -Scope Global -Force -ErrorAction SilentlyContinue } foreach ($e in $PwrSaveState.env) { Set-Item -Path "env:$($e.name)" -Value $e.value -Force -ErrorAction SilentlyContinue } Remove-Variable 'PwrSaveState' -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) { $pre = "$env:Path$(if ($env:Path) { ';' })" } else { $post = "$(if (-not $env:Path.StartsWith(';')) { ';' })$env:Path" } } 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:PwrLoadedPackages -split ';')) { $Pkg | ConfigurePackage $env:PwrLoadedPackages += "$(if ($env:PwrLoadedPackages) { ';' })$digest" WriteHost "Status: Session configured for $ref" } else { WriteHost "Status: Session is up to date for $ref" } } function ExecuteScript { param ( [Parameter(Mandatory)] [scriptblock]$Script, [Parameter(Mandatory)] [Collections.Hashtable[]]$Pkgs ) try { SaveSessionState ClearSessionState 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" & $Script } finally { RestoreSessionState } } $PwrHelp = @" Usage: pwr 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 "@ # $PwrListHelp = @" # Usage: pwr list [COMMAND] # Lists packages # Commands: # remote, lists remote package # "@ $PwrNoCommand = @" To see avilable commands run pwr help "@ $PwrRemoteNoCommand = @" To see avilable commands run pwr remote help "@ function Flatten { param ( [object[]]$Objs ) $arr = @() foreach ($obj in $Objs) { if ($obj -is [object[]]) { $arr += flatten $obj } else { $arr += $obj } } return $arr } function OrElsePackages { param ( [object[]]$Objs ) if ($Objs) { return $Objs } $cfg = FindConfig if ($cfg) { . $cfg } if (-not $PwrPackages) { throw "no packages provided" } else { return [object[]]$PwrPackages } } function Invoke-Airpower { param ( [Parameter(ValueFromPipeline)] [object]$InputObject, [Parameter(ValueFromRemainingArguments, Position = 0)] [object[]]$Arguments ) process { if ($InputObject) { $Arguments += $InputObject } } end { $ErrorActionPreference = 'Stop' if ($Arguments) { $first, $rest = Flatten $Arguments switch ($first) { {$_ -in 'v', 'version'} { (Get-Module -Name Airpower).Version return } 'remote' { if ($Arguments.Count -eq 2) { switch ($Arguments[1]) { 'list' { GetRemoteTags return } 'help' { 'todo' return } default { $PwrRemoteNoCommand return } } } } 'list' { GetLocalPackages return } 'load' { $pkgs = OrElsePackages $rest foreach ($p in $pkgs) { $p | ResolvePackage | LoadPackage } return } 'pull' { $pkgs = OrElsePackages $rest foreach ($p in $pkgs) { $p | AsPackage | PullPackage } return } 'prune' { PrunePackages return } {$_ -in 'remove', 'rm'} { foreach ($p in $rest) { $p | AsPackage | RemovePackage } return } 'exec' { if ($rest.Count -eq 0) { throw "no scriptblock provided" } elseif ($rest.Count -eq 1) { $script = $rest $pkgs = OrElsePackages $null } else { $pkgs, $script = $rest[0..$($rest.Count - 2)], $rest[$($rest.Count - 1)] } if ($script -isnot [scriptblock]) { throw "'$script' is not a script" } $resolved = @() foreach ($p in $pkgs) { $resolved += $p | ResolvePackage } ExecuteScript -Script $script -Pkgs $resolved return } 'run' { $cfg = FindConfig if (-not $cfg) { throw "no config file found" } $first, $rest = $rest if (-not $first) { throw "no script provided" } . $cfg $fn = Get-Item "function:Pwr$first" if ($rest) { $params = @{} $leftover = [object[]]@() for ($i = 0; $i -lt $rest.Count; $i++) { if ($fn.parameters.keys -and ($rest[$i] -match '^-([^:]+)(?::(.*))?$') -and ($Matches[1] -in $fn.parameters.keys)) { if ($Matches[2]) { $params.$($Matches[1]) = $Matches[2] } else { $params.$($Matches[1]) = $rest[$i+1] $i++ } } else { $leftover += $rest[$i] } } & $fn @params @leftover } else { & $fn } return } {$_ -in 'help', 'h'} { $PwrHelp return } } } throw $PwrNoCommand } } Set-Alias -Name 'airpower' -Value 'Invoke-Airpower' -Scope Global Set-Alias -Name 'pwr' -Value 'Invoke-Airpower' -Scope Global |