diff --git a/.cspell.jsonc b/.cspell.jsonc new file mode 100644 index 0000000..a381d53 --- /dev/null +++ b/.cspell.jsonc @@ -0,0 +1,10 @@ +{ + "words": [ + "anypackage", + "Authenticode", + "LASTEXITCODE", + "Nieto", + "SARIF", + "trunc" + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4bebf4c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,132 @@ +name: CI + +defaults: + run: + shell: pwsh + +on: + push: + branches: [ main ] + + pull_request: + branches: [ main ] + + release: + types: [ published ] + +jobs: + Build: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Upload module + uses: actions/upload-artifact@v4 + with: + name: module + path: ./src/ + + Test: + needs: Build + strategy: + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install AnyPackage + run: Install-Module AnyPackage -Force -AllowClobber + + - name: Download module + uses: actions/download-artifact@v4 + with: + name: module + path: AnyPackage.Docker + + - name: Move module + run: | + if ($IsWindows) { + $path = "$HOME\Documents\PowerShell\Modules" + } else { + $path = "$HOME/.local/share/powershell/Modules" + } + + Move-Item AnyPackage.Docker $path + + - name: Test with Pester + run: | + $ht = Import-PowerShellDataFile PesterSettings.psd1 + $config = New-PesterConfiguration $ht + Invoke-Pester -Configuration $config + + Sign: + needs: Test + if: github.event_name == 'release' && github.event.action == 'published' + runs-on: windows-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Download module + uses: actions/download-artifact@v4 + with: + name: module + path: module + + - name: Import certificate + env: + CERTIFICATE_BASE64: ${{ secrets.CERTIFICATE_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} + CERTIFICATE_PASSWORD_KEY_BASE64: ${{ secrets.CERTIFICATE_PASSWORD_KEY_BASE64 }} + run: | + [convert]::FromBase64String($env:CERTIFICATE_BASE64) | Set-Content -Path cert.pfx -AsByteStream + $key = [convert]::FromBase64String($env:CERTIFICATE_PASSWORD_KEY_BASE64) + $password = ConvertTo-SecureString $env:CERTIFICATE_PASSWORD -Key $key + Import-PfxCertificate cert.pfx -Password $password -CertStoreLocation Cert:\CurrentUser\My + + - name: Sign files + run: | + $config = Import-PowerShellDataFile SignSettings.psd1 + $config['Certificate'] = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert + Set-Location .\module + Set-AuthenticodeSignature @config + + - name: Create and sign catalog file + run: | + $config = Import-PowerShellDataFile SignSettings.psd1 + $config['FilePath'] = 'AnyPackage.Docker.cat' + $config['Certificate'] = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert + Set-Location .\module + New-FileCatalog $config['FilePath'] -CatalogVersion 2 + Set-AuthenticodeSignature @config + + - name: Upload module + uses: actions/upload-artifact@v4 + with: + name: module-signed + path: ./module/ + + Publish: + needs: Sign + if: github.event_name == 'release' && github.event.action == 'published' + runs-on: ubuntu-latest + steps: + + - name: Download module + uses: actions/download-artifact@v4 + with: + name: module-signed + path: '~/.local/share/powershell/Modules/AnyPackage.Docker' + + - name: Install AnyPackage + run: Install-Module AnyPackage -Force -AllowClobber + + - name: Publish Module + env: + NUGET_KEY: ${{ secrets.NUGET_KEY }} + run: | + $module = Get-Module AnyPackage.Docker -ListAvailable + Publish-PSResource $module.ModuleBase -ApiKey $env:NUGET_KEY diff --git a/.github/workflows/lint-powershell.yml b/.github/workflows/lint-powershell.yml new file mode 100644 index 0000000..72a6155 --- /dev/null +++ b/.github/workflows/lint-powershell.yml @@ -0,0 +1,43 @@ +name: PSScriptAnalyzer + +defaults: + run: + shell: pwsh + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '22 1 * * 3' + +permissions: + contents: read + +jobs: + build: + permissions: + contents: read + security-events: write + name: PSScriptAnalyzer + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install AnyPackage + run: Install-Module AnyPackage -Force -AllowClobber + + - name: Install ConvertToSARIF + run: Install-Module ConvertToSARIF -Force + + - name: Run PSScriptAnalyzer + run: | + Import-Module AnyPackage, ConvertToSARIF -PassThru + Invoke-ScriptAnalyzer -Path . -Recurse | ConvertTo-SARIF -FilePath results.sarif + + - name: Upload SARIF results file + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 0000000..173315f --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,54 @@ +{ + "code-block-style": { + "style": "fenced" + }, + "code-fence-style": { + "style": "backtick" + }, + "emphasis-style": { + "style": "asterisk" + }, + "fenced-code-language": { + "allowed_languages": [ + "powershell", + "text" + ], + "language_only": true + }, + "heading-style": { + "style": "atx" + }, + "hr-style": { + "style": "---" + }, + "line-length": { + "strict": true, + "code_blocks": false + }, + "link-image-style": { + "collapsed": false, + "url_inline": false + }, + "no-duplicate-heading": { + "siblings_only": true + }, + "ol-prefix": { + "style": "ordered" + }, + "proper-names": { + "code_blocks": false, + "names": [ + "PowerShell", + "AnyPackage" + ] + }, + "reference-links-images": { + "shortcut_syntax": true + }, + "strong-style": { + "style": "asterisk" + }, + "ul-style": { + "style": "dash" + } +} diff --git a/PesterSettings.psd1 b/PesterSettings.psd1 new file mode 100644 index 0000000..c0c05f8 --- /dev/null +++ b/PesterSettings.psd1 @@ -0,0 +1,8 @@ +@{ + Run = @{ + Exit = $true + } + Output = @{ + Verbosity = 'Detailed' + } +} diff --git a/SignSettings.psd1 b/SignSettings.psd1 new file mode 100644 index 0000000..cae793c --- /dev/null +++ b/SignSettings.psd1 @@ -0,0 +1,5 @@ +@{ + FilePath = @('AnyPackage.Docker.psd1', 'AnyPackage.Docker.psm1') + TimeStampServer = 'http://timestamp.sectigo.com' + HashAlgorithm = 'SHA256' +} diff --git a/src/AnyPackage.Docker.psd1 b/src/AnyPackage.Docker.psd1 new file mode 100644 index 0000000..6324db2 --- /dev/null +++ b/src/AnyPackage.Docker.psd1 @@ -0,0 +1,24 @@ +@{ + RootModule = 'AnyPackage.Docker.psm1' + ModuleVersion = '0.1.0' + CompatiblePSEditions = @('Desktop', 'Core') + GUID = '4826c4eb-b455-434e-9d1f-253c9e0021fd' + Author = 'Thomas Nieto' + Copyright = '(c) 2025 Thomas Nieto. All rights reserved.' + Description = 'Docker provider for AnyPackage.' + PowerShellVersion = '5.1' + RequiredModules = @('AnyPackage') + FunctionsToExport = @() + CmdletsToExport = @() + AliasesToExport = @() + PrivateData = @{ + AnyPackage = @{ + Providers = 'Docker' + } + PSData = @{ + Tags = @('AnyPackage', 'Provider', 'Docker', 'Container', 'Windows', 'Linux', 'MacOS') + LicenseUri = 'https://github.com/anypackage/docker/blob/main/LICENSE' + ProjectUri = 'https://github.com/anypackage/docker' + } + } +} diff --git a/src/AnyPackage.Docker.psm1 b/src/AnyPackage.Docker.psm1 new file mode 100644 index 0000000..05455c2 --- /dev/null +++ b/src/AnyPackage.Docker.psm1 @@ -0,0 +1,150 @@ +using module AnyPackage +using namespace AnyPackage.Provider +using namespace System.Management.Automation + +[PackageProvider('Docker')] +class DockerProvider : PackageProvider, IGetPackage, IInstallPackage, IUninstallPackage { + [void] GetPackage ([PackageRequest] $request) { + $images = docker image list --format json --no-trunc --digests | ConvertFrom-Json + + foreach ($image in $images) { + try { + $repo = $this.ParseRepository($image.Repository, $request) + } catch { + continue + } + + if ($request.IsMatch($repo.Name, $image.Tag)) { + $metadata = $image | ConvertTo-Metadata + $package = [PackageInfo]::new($repo.Name, $image.Tag, $repo.Source, '', $null, $metadata, $request.ProviderInfo) + $request.WritePackage($package) + } + } + } + + [void] InstallPackage ([PackageRequest] $request) { + $id = '' + + if ($request.Source) { + $id += "{0}/" -f $request.Source + } + + $id += $request.Name + + if ($request.Version -and $request.Version.MinVersion -ne $request.Version.MaxVersion) { + throw "Version ranges not supported." + } + elseif ($request.Version) { + $id += ":{0}" -f $request.Version.MinVersion + } + + $request.WriteVerbose("Full repository name: $id") + + docker pull $id 2>&1 | + ForEach-Object { + if ($_ -is [ErrorRecord]) { + $request.WriteError($_) + } else { + $request.WriteVerbose($_) + } + } + + if ($LASTEXITCODE -eq 0) { + $getPackageParameters = @{ + Name = $request.Name + Provider = $request.ProviderInfo.FullName + ErrorAction = 'SilentlyContinue' + } + + if ($request.Version) { + $getPackageParameters['Version'] = $request.Version + } + + $package = Get-Package @getPackageParameters + + $request.WritePackage($package) + } + } + + [void] UninstallPackage ([PackageRequest] $request) { + $getPackageParameters = @{ + Name = $request.Name + Provider = $request.ProviderInfo.FullName + ErrorAction = 'SilentlyContinue' + } + + if ($request.Version) { + $getPackageParameters['Version'] = $request.Version + } + + $package = Get-Package @getPackageParameters + + if (!$package) { + return + } + + $id = '{0}/{1}:{2}' -f $package.Source, $package.Name, $package.Version + docker image rm $id 2>&1 | + ForEach-Object { + if ($_ -is [ErrorRecord]) { + $request.WriteError($_) + } else { + $request.WriteVerbose($_) + } + } + + if ($LASTEXITCODE -eq 0) { + $request.WritePackage($package) + } + } + + hidden [hashtable] ParseRepository([string] $repository, [PackageRequest] $request) { + if ($repository -match '^(?:(?[^/]*\.[^/]+)/)?(?.+)$') { + if ($matches.ContainsKey('source')) { + $source = [PackageSourceInfo]::new($matches.source, $matches.source, $request.ProviderInfo) + } else { + $request.WriteVerbose('Host not found, defaulting to default docker.io') + $source = [PackageSourceInfo]::new('docker.io', 'docker.io', $request.ProviderInfo) + } + } else { + throw "Failed to parse $($repository)" + } + + return @{ + Name = $matches.Name + Source = $source + } + } +} + +[guid] $id = '9782932d-8d88-4f53-9ef9-4dbf1def9783' +[PackageProviderManager]::RegisterProvider($id, [DockerProvider], $MyInvocation.MyCommand.ScriptBlock.Module) + +$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { + [PackageProviderManager]::UnregisterProvider($id) +} + +function ConvertTo-Metadata { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] + [CmdletBinding()] + [OutputType([hashtable])] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [PSObject] + $InputObject + ) + + process { + $ht = @{ } + + $properties = $InputObject | + Get-Member -MemberType Properties | + Select-Object -ExpandProperty Name + + foreach ($prop in $properties) { + $ht[$prop] = $InputObject.$prop + } + + $ht + } +} diff --git a/test/Get-PackageProvider.Tests.ps1 b/test/Get-PackageProvider.Tests.ps1 new file mode 100644 index 0000000..eb5679e --- /dev/null +++ b/test/Get-PackageProvider.Tests.ps1 @@ -0,0 +1,10 @@ +#Requires -Modules AnyPackage.Docker + +Describe Get-PackageProvider { + Context 'with no parameters' { + It 'should return results' { + Get-PackageProvider | + Should -Not -BeNullOrEmpty + } + } +}