Wsl-Manager.psm1

# Copyright 2022 Antoine Martin
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

using namespace System.IO;

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseApprovedVerbs', '', Scope = 'Function', Target = "Wrap")]
Param()

$module_directory = ([System.IO.FileInfo]$MyInvocation.MyCommand.Path).DirectoryName
$base_wsl_directory = "$env:LOCALAPPDATA\Wsl"

class UnknownDistributionException : System.SystemException {
    UnknownDistributionException([string] $Name) : base("Unknown distribution(s): $Name") {
    }
}

class DistributionAlreadyExistsException: System.SystemException {
    DistributionAlreadyExistsException([string] $Name) : base("Distribution $Name already exists") {
    }
}

if ($PSVersionTable.PSVersion.Major -lt 6) {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidAssignmentToAutomaticVariable', $null, Scope = 'Function')]
    $IsWindows = $true
}

if ($IsWindows) {
    $wslPath = "$env:windir\system32\wsl.exe"
    if (-not [System.Environment]::Is64BitProcess) {
        # Allow launching WSL from 32 bit powershell
        $wslPath = "$env:windir\sysnative\wsl.exe"
    }

}
else {
    # If running inside WSL, rely on wsl.exe being in the path.
    $wslPath = "wsl.exe"
}


# Helper that will launch wsl.exe, correctly parsing its output encoding, and throwing an error
# if it fails.
function Wrap-Wsl {
    $hasError = $false
    try {
        $oldOutputEncoding = [System.Console]::OutputEncoding
        [System.Console]::OutputEncoding = [System.Text.Encoding]::Unicode
        $output = &$wslPath $args
        if ($LASTEXITCODE -ne 0) {
            throw "wsl.exe failed: $output"
            $hasError = $true
        }

    }
    finally {
        [System.Console]::OutputEncoding = $oldOutputEncoding
    }

    # $hasError is used so there's no output in case error action is silently continue.
    if (-not $hasError) {
        return $output
    }
}

enum WslDistributionState {
    Stopped
    Running
    Installing
    Uninstalling
    Converting
}

# Represents a WSL distribution.
class WslDistribution {
    WslDistribution() {
    }

    [string] ToString() {
        return $this.Name
    }

    [string] Unregister() {
        return Wrap-Wsl --unregister $this.Name
    }

    [void] Stop() {
        Progress "Stopping $($this.Name)..."
        $null = Wrap-Wsl --terminate $this.Name
        Success "[ok]"
    }

    [Microsoft.Win32.RegistryKey]GetRegistryKey() {
        return Get-ChildItem HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss |  Where-Object { $_.GetValue('DistributionName') -eq $this.Name }
    }

    [void]Rename([string]$NewName) {
        $existing = Get-Wsl $NewName -ErrorAction SilentlyContinue
        if ($null -ne $existing) {
            throw [DistributionAlreadyExistsException]$NewName
        }
        $this.GetRegistryKey() | Set-ItemProperty -Name DistributionName -Value $NewName
        $this.Name = $NewName
    }

    [ValidateNotNullOrEmpty()][string]$Name
    [WslDistributionState]$State
    [int]$Version
    [bool]$Default
    [Guid]$Guid
    [FileSystemInfo]$BasePath
}



# Helper to parse the output of wsl.exe --list
function Get-WslHelper() {
    Wrap-Wsl --list --verbose | Select-Object -Skip 1 | ForEach-Object { 
        $fields = $_.Split(@(" "), [System.StringSplitOptions]::RemoveEmptyEntries) 
        $defaultDistro = $false
        if ($fields.Count -eq 4) {
            $defaultDistro = $true
            $fields = $fields | Select-Object -Skip 1
        }

        [WslDistribution]@{
            "Name"    = $fields[0]
            "State"   = $fields[1]
            "Version" = [int]$fields[2]
            "Default" = $defaultDistro
        }
    }
}


# Helper to get additional distribution properties from the registry.
function Get-WslProperties([WslDistribution]$Distribution) {
    $key = Get-ChildItem "hkcu:\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss" | Get-ItemProperty | Where-Object { $_.DistributionName -eq $Distribution.Name }
    if ($key) {
        $Distribution.Guid = $key.PSChildName
        $path = $key.BasePath
        if ($path.StartsWith("\\?\")) {
            $path = $path.Substring(4)
        }

        $Distribution.BasePath = Get-Item -Path $path
    }
}

function Get-Wsl {
    <#
    .SYNOPSIS
        Gets the WSL distributions installed on the computer.
    .DESCRIPTION
        The Get-Wsl cmdlet gets objects that represent the WSL distributions on the computer.
        This cmdlet wraps the functionality of "wsl.exe --list --verbose".
    .PARAMETER Name
        Specifies the distribution names of distributions to be retrieved. Wildcards are permitted. By
        default, this cmdlet gets all of the distributions on the computer.
    .PARAMETER Default
        Indicates that this cmdlet gets only the default distribution. If this is combined with other
        parameters such as Name, nothing will be returned unless the default distribution matches all the
        conditions. By default, this cmdlet gets all of the distributions on the computer.
    .PARAMETER State
        Indicates that this cmdlet gets only distributions in the specified state (e.g. Running). By
        default, this cmdlet gets all of the distributions on the computer.
    .PARAMETER Version
        Indicates that this cmdlet gets only distributions that are the specified version. By default,
        this cmdlet gets all of the distributions on the computer.
    .INPUTS
        System.String
        You can pipe a distribution name to this cmdlet.
    .OUTPUTS
        WslDistribution
        The cmdlet returns objects that represent the distributions on the computer.
    .EXAMPLE
        Get-Wsl
        Name State Version Default
        ---- ----- ------- -------
        Ubuntu Stopped 2 True
        Ubuntu-18.04 Running 1 False
        Alpine Running 2 False
        Debian Stopped 1 False
        Get all WSL distributions.
    .EXAMPLE
        Get-Wsl -Default
        Name State Version Default
        ---- ----- ------- -------
        Ubuntu Stopped 2 True
        Get the default distribution.
    .EXAMPLE
        Get-Wsl -Version 2 -State Running
        Name State Version Default
        ---- ----- ------- -------
        Alpine Running 2 False
        Get running WSL2 distributions.
    .EXAMPLE
        Get-Wsl Ubuntu* | Stop-WslDistribution
        Terminate all distributions that start with Ubuntu
    .EXAMPLE
        Get-Content distributions.txt | Get-Wsl
        Name State Version Default
        ---- ----- ------- -------
        Ubuntu Stopped 2 True
        Debian Stopped 1 False
        Use the pipeline as input.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false, ValueFromPipeline = $true)]
        [ValidateNotNullOrEmpty()]
        [SupportsWildcards()]
        [string[]]$Name,
        [Parameter(Mandatory = $false)]
        [Switch]$Default,
        [Parameter(Mandatory = $false)]
        [WslDistributionState]$State,
        [Parameter(Mandatory = $false)]
        [int]$Version
    )

    process {
        $distributions = Get-WslHelper
        if ($Default) {
            $distributions = $distributions | Where-Object {
                $_.Default
            }
        }

        if ($PSBoundParameters.ContainsKey("State")) {
            $distributions = $distributions | Where-Object {
                $_.State -eq $State
            }
        }

        if ($PSBoundParameters.ContainsKey("Version")) {
            $distributions = $distributions | Where-Object {
                $_.Version -eq $Version
            }
        }

        if ($Name.Length -gt 0) {
            $distributions = $distributions | Where-Object {
                foreach ($pattern in $Name) {
                    if ($_.Name -ilike $pattern) {
                        return $true
                    }
                }
                
                return $false
            }
            if ($null -eq $distributions) {
                throw [UnknownDistributionException]::new($Name)
            }
        }

        # The additional registry properties aren't available if running inside WSL.
        if ($IsWindows) {
            $distributions | ForEach-Object {
                Get-WslProperties $_
            }
        }

        return $distributions
    }
}

function Install-Wsl {
    <#
    .SYNOPSIS
        Installs and configure a minimal WSL distribution.

    .DESCRIPTION
        This command performs the following operations:
        - Create a Distribution directory
        - Download the Root Filesystem if needed.
        - Create the WSL distribution.
        - Configure the WSL distribution if needed.

        The distribution is configured as follow:
        - A user named after the name of the distribution (arch, alpine or
        ubuntu) is set as the default user.
        - zsh with oh-my-zsh is used as shell.
        - `powerlevel10k` is set as the default oh-my-zsh theme.
        - `zsh-autosuggestions` plugin is installed.

    .PARAMETER Name
        The name of the distribution.

    .PARAMETER Distribution
        The identifier of the distribution. It can be an already known name:
        - Arch
        - Alpine
        - Ubuntu
        - Debian

        It also can be the URL (https://...) of an existing filesystem or a
        distribution name saved through Export-Wsl.

        It can also be a name in the form:

            lxd:<os>:<release> (ex: lxd:rockylinux:9)
        
        In this case, it will fetch the last version the specified image in
        https://uk.lxd.images.canonical.com/images.

    .PARAMETER Configured
        If provided, install the configured version of the root filesystem.

    .PARAMETER BaseDirectory
        Base directory where to create the distribution directory. Equals to
        $env:APPLOCALDATA\Wsl (~\AppData\Local\Wsl) by default.

    .PARAMETER DefaultUid
        Default user. 1000 by default.

    .PARAMETER SkipConfigure
        Skip Configuration. Only relevant for already known distributions.

    .INPUTS
        None.

    .OUTPUTS
        None.

    .EXAMPLE
        Install-Wsl alpine
        Install an Alpine based WSL distro named alpine.
    
    .EXAMPLE
        Install-Wsl arch -Distribution Arch
        Install an Arch based WSL distro named arch.

    .EXAMPLE
        Install-Wsl arch -Distribution Arch -Configured
        Install an Arch based WSL distro named arch from the already configured image.

    .EXAMPLE
        Install-Wsl rocky -Distribution lxd:rocky:9
        Install a Rocky Linux based WSL distro named rocky.

    .EXAMPLE
        Install-Wsl lunar -Distribution https://cloud-images.ubuntu.com/wsl/lunar/current/ubuntu-lunar-wsl-amd64-wsl.rootfs.tar.gz -SkipCofniguration
        Install a Ubuntu 23.04 based WSL distro named lunar from the official Canonical root filesystem and skip configuration.

    .LINK
        Uninstall-Wsl
        https://github.com/romkatv/powerlevel10k
        https://github.com/zsh-users/zsh-autosuggestions
        https://github.com/antoinemartin/wsl2-ssh-pageant-oh-my-zsh-plugin

    .NOTES
        The command tries to be indempotent. It means that it will try not to
        do an operation that already has been done before.

    #>
    
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Position = 0, Mandatory = $true)]
        [string]$Name,
        [string]$Distribution = 'Alpine',
        [Parameter(Mandatory = $false)]
        [switch]$Configured,
        [string]$BaseDirectory = $base_wsl_directory,
        [Int]$DefaultUid = 1000,
        [Parameter(Mandatory = $false)]
        [switch]$SkipConfigure
    )

    # Retrieve the distribution if it already exists
    $current_distribution = Get-Wsl $Name -ErrorAction SilentlyContinue

    if ($null -ne $current_distribution) {
        throw [DistributionAlreadyExistsException] $Name
    }

    # Where to install the distribution
    $distribution_dir = "$BaseDirectory\$Name"

    # Create the directory
    If (!(test-path $distribution_dir)) {
        Progress "Creating directory [$distribution_dir]..."
        if ($PSCmdlet.ShouldProcess($distribution_dir, 'Create Distribution directory')) {
            $null = New-Item -ItemType Directory -Force -Path $distribution_dir
        }
    }
    else {
        Information "Distribution directory [$distribution_dir] already exists."
    }

    $rootfs = [WslRootFileSystem]::new($Distribution, $Configured)
    if ($PSCmdlet.ShouldProcess($rootfs.Url, 'Synchronize locally')) {
        $null = $rootfs | Sync-WslRootFileSystem
    }
    $rootfs_file = $rootfs.File.FullName

    Progress "Creating distribution [$Name] from [$rootfs_file]..."
    if ($PSCmdlet.ShouldProcess($Name, 'Create distribution')) {
        &$wslPath --import $Name $distribution_dir $rootfs_file | Write-Verbose
    }

    if ($false -eq $SkipConfigure) {
        if ($PSCmdlet.ShouldProcess($Name, 'Configure distribution')) {
            if (!$rootfs.AlreadyConfigured) {
                Progress "Running initialization script [configure.sh] on distribution [$Name]..."
                Push-Location "$module_directory"
                &$wslPath -d $Name -u root ./configure.sh 2>&1 | Write-Verbose
                Pop-Location
                if ($LASTEXITCODE -ne 0) {
                    throw "Configuration failed"
                }        
            }
            Get-ChildItem HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss |  Where-Object { $_.GetValue('DistributionName') -eq $Name } | Set-ItemProperty -Name DefaultUid -Value 1000
        }
    }

    Success "Done. Command to enter distribution: wsl -d $Name"
    ## More Stuff ?
    # To import your publick keys and use the yubikey for signing.
    # gpg --keyserver keys.openpgp.org --search antoine@mrtn.fr
}

function Uninstall-Wsl {
    <#
    .SYNOPSIS
        Uninstalls Arch Linux based WSL distribution.

    .DESCRIPTION
        This command unregisters the specified distribution. It also deletes the
        distribution base root filesystem and the directory of the distribution.

    .PARAMETER Name
        The name of the distribution. Wildcards are permitted.
    
    .PARAMETER Distribution
        Specifies WslDistribution objects that represent the distributions to be removed.
    
    .PARAMETER KeepDirectory
        If specified, keep the distribution directory. This allows recreating
        the distribution from a saved root file system.

    .INPUTS
        WslDistribution, System.String
        
        You can pipe a WslDistribution object retrieved by Get-Wsl,
        or a string that contains the distribution name to this cmdlet.

    .OUTPUTS
        None.

    .EXAMPLE
        Uninstall-Wsl toto

        Uninstall distribution named toto.
    
    .EXAMPLE
        Uninstall-Wsl test*

        Uninstall all distributions which names start by test.

    .EXAMPLE
        Get-Wsl -State Stopped | Sort-Object -Property -Size -Last 1 | Uninstall-Wsl

        Uninstall the largest non running distribution.

    .LINK
        Install-Wsl
        https://github.com/romkatv/powerlevel10k
        https://github.com/zsh-users/zsh-autosuggestions
        https://github.com/antoinemartin/wsl2-ssh-pageant-oh-my-zsh-plugin

    .NOTES
        The command tries to be indempotent. It means that it will try not to
        do an operation that already has been done before.

    #>
    
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = "DistributionName", Position = 0)]
        [ValidateNotNullOrEmpty()]
        [SupportsWildCards()]
        [string[]]$Name,
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = "Distribution")]
        [WslDistribution[]]$Distribution,
        [Parameter(Mandatory = $false)]
        [switch]$KeepDirectory
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq "DistributionName") {
            $Distribution = Get-Wsl $Name
        }

        if ($null -ne $Distribution) {
            $Distribution | ForEach-Object {
                if ($PSCmdlet.ShouldProcess($_.Name, "Unregister")) {
                    $_.Unregister() | Write-Verbose
                    if ($false -eq $KeepDirectory) {
                        $_.BasePath | Remove-Item -Recurse
                    }
                }
            }
        }
    }
}

function Export-Wsl {
    <#
    .SYNOPSIS
        Exports the file system of an Arch Linux WSL distrubtion.

    .DESCRIPTION
        This command exports the distribution and tries to compress it with
        the `gzip` command embedded in the distribution. If no destination file
        is given, it replaces the root filesystem file in the distribution
        directory.

    .PARAMETER Name
        The name of the distribution. If ommitted, will take WslArch by
        default.

    .PARAMETER OutputName
        Name of the output distribution. By default, uses the name of the
        distribution.
    
    .PARAMETER Destination
        Base directory where to save the root file system. Equals to
        $env:APPLOCALDAT\Wsl\RootFS (~\AppData\Local\Wsl\RootFS) by default.

    .PARAMETER OutputFile
        The name of the output file. If it is not specified, it will overwrite
        the root file system of the distribution.

    .INPUTS
        None.

    .OUTPUTS
        None.

    .EXAMPLE
        Install-Wsl toto
        wsl -d toto -u root apk add openrc docker
        Export-Wsl toto docker

        Uninstall-Wsl toto
        Install-Wsl toto -Distribution docker
    
    .LINK
        Install-Wsl
        https://github.com/romkatv/powerlevel10k
        https://github.com/zsh-users/zsh-autosuggestions
        https://github.com/antoinemartin/wsl2-ssh-pageant-oh-my-zsh-plugin

    .NOTES
        The command tries to be indempotent. It means that it will try not to
        do an operation that already has been done before.

    #>
    
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Position = 0, Mandatory = $true)]
        [string]$Name,
        [Parameter(Position = 1, Mandatory = $false)]
        [string]$OutputName,
        [string]$Destination = [WslRootFileSystem]::BasePath.FullName,
        [Parameter(Mandatory = $false)]
        [string]$OutputFile
    )

    # Retrieve the distribution if it already exists
    [WslDistribution]$Distribution = Get-Wsl $Name

    if ($null -ne $Distribution) {
        $Distribution | ForEach-Object {


            if ($OutputFile.Length -eq 0) {
                if ($OutputName.Length -eq 0) {
                    $OutputName = $Distribution.Name
                }
                $OutputFile = "$Destination\$OutputName.rootfs.tar.gz"
                If (!(test-path -PathType container $Destination)) {
                    if ($PSCmdlet.ShouldProcess($Destination, 'Create Wsl base directory')) {
                        $null = New-Item -ItemType Directory -Path $Destination
                    }
                }
            }

            if ($PSCmdlet.ShouldProcess($Distribution.Name, 'Export distribution')) {


                $export_file = $OutputFile -replace '\.gz$'

                Progress "Exporting WSL distribution $Name to $export_file..."
                Wrap-Wsl --export $Distribution.Name "$export_file" | Write-Verbose
                $file_item = Get-Item -Path "$export_file"
                $filepath = $file_item.Directory.FullName
                Progress "Compressing $export_file to $OutputFile..."
                Remove-Item "$OutputFile" -Force -ErrorAction SilentlyContinue
                Wrap-Wsl -d $Name --cd "$filepath" gzip $file_item.Name | Write-Verbose

                $props =  Invoke-Wsl -DistributionName $Name cat /etc/os-release | ForEach-Object { $_ -replace '=([^"].*$)','="$1"' } | Out-String | ForEach-Object {"@{`n$_`n}"} | Invoke-Expression

                [PSCustomObject]@{
                    Os                = $OutputName
                    Release           = $props.VERSION_ID
                    Type              = [WslRootFileSystemType]::Local.ToString()
                    State             = [WslRootFileSystemState]::Synced.ToString()
                    Url               = $null
                    AlreadyConfigured = $true
                } | ConvertTo-Json | Set-Content -Path "$($OutputFile).json"
        

                Success "Distribution $Name saved to $OutputFile."
                return [WslRootFileSystem]::new([FileInfo]::new($OutputFile))
            }
        }
    }
}

function Invoke-Wsl {
    <#
    .SYNOPSIS
        Runs a command in one or more WSL distributions.
    .DESCRIPTION
        The Invoke-Wsl cmdlet executes the specified command on the specified distributions, and
        then exits.
        This cmdlet will raise an error if executing wsl.exe failed (e.g. there is no distribution with
        the specified name) or if the command itself failed.
        This cmdlet wraps the functionality of "wsl.exe <command>".
    .PARAMETER DistributionName
        Specifies the distribution names of distributions to run the command in. Wildcards are permitted.
        By default, the command is executed in the default distribution.
    .PARAMETER Distribution
        Specifies WslDistribution objects that represent the distributions to run the command in.
        By default, the command is executed in the default distribution.
    .PARAMETER User
        Specifies the name of a user in the distribution to run the command as. By default, the
        distribution's default user is used.
    .PARAMETER Arguments
        Command and arguments to pass to the
    .INPUTS
        WslDistribution, System.String
        You can pipe a WslDistribution object retrieved by Get-WslDistribution, or a string that contains
        the distribution name to this cmdlet.
    .OUTPUTS
        System.String
        This command outputs the result of the command you executed, as text.
    .EXAMPLE
        Invoke-Wsl ls /etc
        Runs a command in the default distribution.
    .EXAMPLE
        Invoke-Wsl -DistributionName Ubuntu* -User root whoami
        Runs a command in all distributions whose names start with Ubuntu, as the "root" user.
    .EXAMPLE
        Get-Wsl -Version 2 | Invoke-Wsl sh "-c" 'echo distro=$WSL_DISTRO_NAME,defautl_user=$(whoami),flavor=$(cat /etc/os-release | grep ^PRETTY | cut -d= -f 2)'
        Runs a command in all WSL2 distributions.
    #>


    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $false, ValueFromPipeline = $true, ParameterSetName = "DistributionName")]
        [ValidateNotNullOrEmpty()]
        [SupportsWildCards()]
        [string[]]$DistributionName,
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ParameterSetName = "Distribution")]
        [WslDistribution[]]$Distribution,
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$User,
        [Parameter(Mandatory = $true, Position = 0, ValueFromRemainingArguments)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Arguments
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq "DistributionName") {
            if ($DistributionName) {
                $Distribution = Get-Wsl $DistributionName
            }
            else {
                $Distribution = Get-Wsl -Default
            }
        }

        $Distribution | ForEach-Object {
            $actualArgs = @("--distribution", $_.Name)
            if ($User) {
                $actualArgs += @("--user", $User)
            }

            # Invoke /bin/bash so the whole command can be passed as a single argument.
            $actualArgs += $Arguments

            if ($PSCmdlet.ShouldProcess($_.Name, "Invoke Command")) {
                &$wslPath @actualArgs
            }
        }
    }
}

$tabCompletionScript = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
    (Get-WslHelper).Name | Where-Object { $_ -ilike "$wordToComplete*" } | Sort-Object
}

Register-ArgumentCompleter -CommandName Get-Wsl,Uninstall-Wsl,Export-Wsl -ParameterName Name -ScriptBlock $tabCompletionScript
Register-ArgumentCompleter -CommandName Invoke-Wsl -ParameterName DistributionName -ScriptBlock $tabCompletionScript
Register-ArgumentCompleter -CommandName Install-Wsl -ParameterName Distribution -ScriptBlock { [WslRootFileSystem]::Distributions.keys }

Export-ModuleMember Install-Wsl
Export-ModuleMember Uninstall-Wsl
Export-ModuleMember Export-Wsl
Export-ModuleMember Get-Wsl
Export-ModuleMember Invoke-Wsl
Export-ModuleMember New-WslRootFileSystem
Export-ModuleMember Sync-WslRootFileSystem
Export-ModuleMember Get-WslRootFileSystem
Export-ModuleMember Remove-WslRootFileSystem
Export-ModuleMember Get-LXDRootFileSystem
Export-ModuleMember New-WslRootFileSystemHash