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 ) if ($null -eq $Object) { return } $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 GetAirpowerAutoprune { if ($AirpowerAutoprune) { $AirpowerAutoprune } elseif ($env:AirpowerAutoprune) { $env:AirpowerAutoprune } } function GetPwrDBPath { "$(GetAirpowerPath)\cache" } 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 ) New-Item -Path $Path -ItemType Directory -ErrorAction Ignore } 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 SetCursorVisible { param ( [Parameter(Mandatory)] [bool]$Enable ) try { [Console]::CursorVisible = $Enable } catch { Write-Error $_ -ErrorAction Ignore } } 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)] } 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 -ge $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 DebugRateLimit { param ( [Parameter(Mandatory, ValueFromPipeline)] [Net.Http.HttpResponseMessage]$Resp ) if ($resp.Headers.Contains('ratelimit-limit')) { Write-Debug "DockerHub RateLimit = $($resp.Headers.GetValues('ratelimit-limit'))" } if ($resp.Headers.Contains('ratelimit-remaining')) { Write-Debug "DockerHub Remaining = $($resp.Headers.GetValues('ratelimit-remaining'))" } } 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" if ((Test-Path $path) -and (Get-FileHash $path).Hash -eq $sha256) { return $path } 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 if (-not $resp.IsSuccessStatusCode) { throw "cannot download blob $($Digest): $($resp.ReasonPhrase)" } $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 } class FileLock { hidden [IO.FileStream]$File hidden [IO.MemoryStream]$Buffer hidden [string]$Path hidden [IO.FileAccess]$Access hidden [bool]$Delete [string[]]$Key FileLock([string]$Path, [IO.FileAccess]$Access, [string[]]$Key) { $this.Key = $Key $this.Access = $Access $this.Path = $Path $this.File = [IO.FileStream]::new($Path, [IO.FileMode]::OpenOrCreate, $Access, [IO.FileShare]::Read) if ($Access -eq [IO.FileAccess]::ReadWrite) { $this.Buffer = [IO.MemoryStream]::new() } } static [FileLock] RLock([string]$Path, [string[]]$Key) { return [FileLock]::new($Path, [IO.FileAccess]::Read, $Key) } static [FileLock] Lock([string]$Path, [string[]]$Key) { return [FileLock]::new($Path, [IO.FileAccess]::ReadWrite, $Key) } Unlock() { if ($this.Buffer) { if ($this.Buffer.Length -gt 0) { $this.File.SetLength(0) $this.Buffer.WriteTo($this.File) } } $this.File.Dispose() if ($this.Delete) { [IO.File]::Delete($this.Path) } } Revert() { $this.File.Dispose() } Remove() { $this.Delete = $this.Access -eq [IO.FileAccess]::ReadWrite } [object] Get() { $b = [byte[]]::new($this.File.Length) $this.File.Read($b, 0, $this.File.Length) return [Db]::Decode([Text.Encoding]::UTF8.GetString($b)) } Put([object]$Value) { $content = [Db]::Encode($value) $this.Buffer.Write([Text.Encoding]::UTF8.GetBytes($content), 0, [Text.Encoding]::UTF8.GetByteCount($content)) } } class Db { static [string]$Dir = (GetPwrDBPath) static Db() { [Db]::Init() } static Init() { MakeDirIfNotExist ([Db]::Dir) } static Remove([string[]]$key) { [IO.File]::Delete("$([Db]::Dir)\$([Db]::Key($key))") } static [object] Get([string[]]$key) { return [Db]::Decode([IO.File]::ReadAllText("$([Db]::Dir)\$([Db]::Key($key))")) } static [object[]] TryGet([string[]]$key) { try { return [Db]::Get($key), $null } catch { return $null, $_ } } static Put([string[]]$key, [object]$value) { [IO.File]::WriteAllText("$([Db]::Dir)\$([Db]::Key($key))", [Db]::Encode($value)) } static [bool] TryPut([string[]]$key, [object]$value) { try { [Db]::TryPut($key, $value) return $true } catch { return $false } } static [string] Encode([object]$value) { $json = $value | ConvertTo-Json -Compress -Depth 10 if ($null -eq $json) { $json = 'null' # PS 5.1 does not handle null } return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) } static [object] Decode([string]$value) { return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($value)) | ConvertFrom-Json } static [FileLock] Lock([string[]]$key) { return [FileLock]::Lock("$([Db]::Dir)\$([Db]::Key($key))", $key) } static [object[]] TryLock([string[]]$key) { try { return [Db]::Lock($key), $null } catch { return $null, $_ } } static [FileLock] RLock([string[]]$key) { return [FileLock]::RLock("$([Db]::Dir)\$([Db]::Key($key))", $key) } static [object[]] TryRLock([string[]]$key) { try { return [Db]::RLock($key), $null } catch { return $null, $_ } } static [string] Key([string[]]$key) { $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($key -join "`n")) return $b64.Replace('/', '_').Replace('+', '-') } static [string[]] DecodeKey([string]$b64) { return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64.Replace('_', '/').Replace('-', '+'))) -split "`n" } static [bool] HasPrefix([string]$b64, [string[]]$key) { $s = [Db]::DecodeKey($b64) for($i=0; $i -lt $key.Length; $i+=1) { if ($key[$i] -ne $s[$i]) { return $false } } return $true } static [bool] ContainsKey([string[]]$key) { return [IO.File]::Exists("$([Db]::Dir)\$([Db]::Key($key))") } static [Entry[]] GetAll([string[]]$key) { $entries = @() foreach ($f in [IO.Directory]::GetFiles([Db]::Dir)) { $k = $f.Substring([Db]::Dir.Length+1) if ([Db]::HasPrefix($k, $key)) { $decodedKey = [Db]::DecodeKey($k) $v, $err = [Db]::TryGet($decodedKey) if (-not $err) { $entries += @{ Key = $decodedKey Value = $v } } } } return $entries } static [object[]] TryLockAll([string[]]$key) { $locks = @() foreach ($f in [IO.Directory]::GetFiles([Db]::Dir)) { $k = $f.Substring([Db]::Dir.Length+1) if ([Db]::HasPrefix($k, $key)) { $decodedKey = [Db]::DecodeKey($k) try { $locks += [Db]::Lock($decodedKey) } catch { if ($locks) { $locks.Revert() } return $null, "a package $($decodedKey) is being used by another airpower process" } } } return $locks, $null } } class Entry { [string[]]$Key [object]$Value } 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 { $pkgs = @() $locks, $err = [Db]::TryLockAll('pkgdb') if ($err) { throw $err } foreach ($lock in $locks) { $tag = $lock.Key[2] $t = [Tag]::new($tag) $digest = if ($t.None) { $tag } else { $lock.Get() } $pkgs += [LocalPackage]@{ Package = $lock.Key[1] Tag = $t Digest = $digest | AsDigest Size = [Db]::Get(('metadatadb', $digest)).size | AsSize } $lock.Unlock() } if (-not $pkgs) { $pkgs = ,[LocalPackage]@{} } return $pkgs } function ResolvePackageDigest { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) if ($pkg.digest) { return $pkg.digest } $k = 'pkgdb', $Pkg.Package, ($Pkg.Tag | AsTagString) if ([Db]::ContainsKey($k)) { return [Db]::Get($k) } } function InstallPackage { # $locks, $status param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $digest = $Pkg.Digest $name = $Pkg.Package $tag = $Pkg.Tag | AsTagString $locks = @() $mLock, $err = [Db]::TryLock(('metadatadb', $digest)) if ($err) { throw "package '$digest' is in use by another airpower process" } $locks += $mLock $pLock, $err = [Db]::TryLock(('pkgdb', $name, $tag)) if ($err) { $locks.Revert() throw "package '${name}:$tag' is in use by another airpower process" } $locks += $pLock $p = $pLock.Get() $m = $mLock.Get() | ConvertTo-HashTable $status = if ($null -eq $p) { if ($null -eq $m) { 'new' } else { 'tag' } } elseif ($digest -ne $p) { 'newer' } else { 'uptodate' } $pLock.Put($digest) switch ($status) { {$_ -in 'new', 'newer'} { $mLock.Put(@{ RefCount = 1 Size = $Pkg.Size }) } 'newer' { $moLock, $err = [Db]::TryLock(('metadatadb', $p)) if ($err) { $locks.Revert() throw "package '$p' is in use by another airpower process" } $locks += $moLock $mo = $moLock.Get() | ConvertTo-HashTable $mo.RefCount -= 1 if ($mo.RefCount -eq 0) { $poLock, $err = [Db]::TryLock(('pkgdb', $name, $p)) if ($err) { $locks.Revert() throw "package '$p' is in use by another airpower process" } $locks += $poLock $poLock.Put($null) $mo.Orphaned = [DateTime]::UtcNow.ToString('u') } $moLock.Put($mo) } 'tag' { if ([Db]::ContainsKey(('pkgdb', $name, $digest))) { $dLock, $err = [Db]::TryLock(('pkgdb', $name, $digest)) if ($err) { $locks.Revert() throw "package '$digest' is in use by another airpower process" } $locks += $dLock $dLock.Remove() } if ($m.RefCount -eq 0 -and $m.Orphaned) { $m.Remove('Orphaned') } $m.RefCount += 1 $mLock.Put($m) } } return $locks, $status } function PullPackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $ref = $Pkg | ResolveRemoteRef $digest = $ref | GetDigestForRef WriteHost "Pulling $($Pkg.Package):$($pkg.Tag | AsTagString)" WriteHost "Digest: $($digest)" $k = 'metadatadb', $digest if ([Db]::ContainsKey($k)) { $m = [Db]::Get($k) $size = $m.Size } else { $manifest = $ref | GetManifest $manifest | DebugRateLimit $size = $manifest | GetSize } $Pkg.Digest = $digest $Pkg.Size = $size $locks, $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 -in 'new', 'newer') { $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" } $locks.Unlock() } function SavePackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [Net.Http.HttpResponseMessage]$Resp ) [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 SetCursorVisible $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 { SetCursorVisible $true } } function UninstallPackage { # $locks, $digest, $err param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $name = $Pkg.Package $tag = $Pkg.Tag | AsTagString $k = 'pkgdb', $name, $tag $locks = @() if (-not [Db]::ContainsKey($k)) { return $null, $null, "package '${name}:$tag' not installed" } $pLock, $err = [Db]::TryLock($k) if ($err) { return $null, $null, "package '${name}:$tag' is in use by another airpower process" } $locks += $pLock $p = $pLock.Get() $pLock.Remove() $mLock, $err = [Db]::TryLock(('metadatadb', $p)) if ($err) { $locks.Revert() $null, $null, "package '$p' is in use by another airpower process" } $locks += $mLock $m = $mLock.Get() if ($m.refcount -gt 0) { $m.refcount -= 1 } if ($m.refcount -eq 0) { $mLock.Remove() $digest = $p } else { $mLock.Put($m) $digest = $null } return $locks, $digest, $null } function RemovePackage { param ( [Parameter(Mandatory, ValueFromPipeline)] [Collections.Hashtable]$Pkg ) $locks, $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) } $locks.Unlock() } function UninstallOrphanedPackages { param ( [timespan]$Span ) $now = [datetime]::UtcNow $locks = @() $metadata = @() $ls, $err = [Db]::TryLockAll('metadatadb') if ($err) { throw $err } foreach ($lock in $ls) { $m = $lock.Get() | ConvertTo-HashTable if ($m.orphaned) { $orphaned = $now - [datetime]::Parse($m.orphaned) } if ($m.refcount -eq 0 -and $orphaned -ge $span) { $locks += $lock $m.digest = $lock.Key[1] $metadata += $m $lock.Remove() } else { $lock.Unlock() } } $ls, $err = [Db]::TryLockAll('pkgdb') if ($err) { if ($locks) { $locks.Revert() } throw $err } foreach ($lock in $ls) { if ($lock.Key[2] -match '^sha256:' -and $lock.Key[2] -in $metadata.digest) { $locks += $lock $lock.Remove() } else { $lock.Unlock() } } return $locks, $metadata } function PrunePackages { param ( [switch]$Auto ) if ($Auto -and -not (GetAirpowerAutoprune)) { return } $span = if ($Auto) { [timespan]::Parse((GetAirpowerAutoprune)) } else { [timespan]::new(0) } $locks, $pruned = UninstallOrphanedPackages $span $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) } } if ($pruned) { WriteHost "Total reclaimed space: $($bytes | AsByteString)" $locks.Unlock() } } 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 ) if ($Ref.StartsWith('file:///')) { return @{ Digest = $Ref Tag = @{} Config = 'default' } } $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 } 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)) } class LocalPackage { [object]$Package [Tag]$Tag [Digest]$Digest [Size]$Size [object]$Orphaned # Signers } 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 } if ($digest.StartsWith('file:///')) { $root = $digest.Substring(8) } else { $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 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 ) 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 & { if ('Airpower.psm1' -eq (Split-Path $MyInvocation.ScriptName -Leaf)) { # Invoked as a module $local = [Version]::new((Import-PowerShellDataFile -Path "$PSScriptRoot\Airpower.psd1").ModuleVersion) $remote = [Version]::new((Get-Package -Name Airpower).Version) if ($remote -gt $local) { WriteHost "$([char]27)[92mA new version of Airpower is available! [v$remote]$([char]27)[0m" } PrunePackages -Auto } } |