seed-docker.ps1


function Test-RunOnLocal {
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [string] $machine
    )
    $local_flags = @('local','localhost')
    return $local_flags.Contains($machine)
}

function Get-DockerRunCommand {
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [PSCustomObject] $docker
    )
    Process {
        $docker_command = New-Object System.Collections.ArrayList
        # https://stackoverflow.com/a/22682725
        $docker_command.Add("docker run") | Out-Null

        $docker_command.Add("--privileged") | Out-Null
        $docker_command.Add("--user root") | Out-Null
        $docker_command.Add("--label com.docker.stack.namespace=seed") | Out-Null
        $docker_command.Add("--label com.github.xiaoyao9184.docker-seed.type=docker") | Out-Null
        $docker_command.Add("--label com.github.xiaoyao9184.docker-seed.creator=docker-seed-cli") | Out-Null
        
        if ($docker.env) {
            $docker.env | ForEach-Object {
                $docker_command.Add("-e '$($_.name)=$($_.value)'") | Out-Null
            }
        }
        if ($docker.volume) {
            $docker.volume | ForEach-Object {
                $docker_command.Add("-v '$($_.source):$($_.target)$($_.read_only ? ':ro' : '')'") | Out-Null
            }
        }
        if ($docker.detach) {
            $docker_command.Add("--detach") | Out-Null
        }
        if ($docker.interactive) {
            $docker_command.Add("--interactive") | Out-Null
        }
        if ($docker.tty) {
            $docker_command.Add("--tty") | Out-Null
        }
        if ($docker.rm) {
            $docker_command.Add("--rm") | Out-Null
        }

        $docker_command.Add("--name $($docker.name)") | Out-Null
        $docker_command.Add("--entrypoint $($docker.entrypoint)") | Out-Null
        $docker_command.Add("$($docker.image)") | Out-Null
        $docker_command.Add("$($docker.command)") | Out-Null

        return $docker_command -join ' '
    }
}

function Start-SeedDocker() {
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [string] $ssh
        ,
        [Parameter(Mandatory = $true,Position = 1)]
        [bool] $background
        ,
        [Parameter(Mandatory = $true,Position = 2)]
        [PSCustomObject] $seed
    )
    
    Write-Verbose ($PSBoundParameters | Format-Table | Out-String)

    $docker = $seed.docker
    $docker.env = New-Object System.Collections.ArrayList
    $docker.volume = New-Object System.Collections.ArrayList

    
    $docker.env.Add(@{
        name = 'SEED_NAME'
        value = $docker.name
    }) | Out-Null
    $docker.env.Add(@{
        name = 'SEED_ENTRYPOINT'
        value = $docker.entrypoint
    }) | Out-Null
    $docker.env.Add(@{
        name = 'SEED_IMAGE'
        value = $docker.image
    }) | Out-Null
    $docker.env.Add(@{
        name = 'SEED_COMMAND'
        value = $docker.command
    }) | Out-Null

    $docker.volume.Add(@{
        source = '/etc/localtime'
        target = '/etc/localtime'
        read_only = $true
    }) | Out-Null

    $docker.volume.Add(@{
        source = '/var/run/docker.sock'
        target = '/var/run/docker.sock'
    }) | Out-Null

    if ($seed.workspace.enable) {
        $docker.env.Add(@{
            name = 'SEED_WORKSPACE'
            value = $seed.workspace.name
        }) | Out-Null
        
        $docker.volume.Add(@{
            source = $seed.workspace.name
            target = '/workspace'
        }) | Out-Null
    }
    if ($seed.key.enable) {
        $docker.env.Add(@{
            name = 'SEED_KEY'
            value = $seed.key.name
        }) | Out-Null
        # key file bind
        # key maybe from command
        # /root/.ssh/ key for docker-seed-remote, so bind to /key/.ssh/id_rsa
        # docker-seed-remote use ansible copy to /seed/key/{{ key.name }}/id_rsa path
        $docker.volume.Add(@{
            source = $seed.key.path
            target = '/key/.ssh/id_rsa'
        }) | Out-Null
    }

    $local = Test-RunOnLocal($ssh)
    if($local){
        if ($seed.key.enable) {
            # for command not specify key
            # key file bind to /root/./ssh/id_rsa path
            $docker.volume.Add(@{
                source = $seed.key.path
                target = '/root/.ssh/id_rsa'
            }) | Out-Null
        } else {
            # key path bind to /root/./ssh path
            $_ssh_path = Resolve-Path -Path "$env:USERPROFILE/.ssh/"
            $docker.volume.Add(@{
                source = $_ssh_path
                target = '/root/.ssh'
            }) | Out-Null
        }

        if($background){
            $docker.detach = $true
        } else {
            $docker.interactive = $true
            $docker.tty = $true
        }
    } else {
        # key path bind to /root/.ssh path
        $_ssh_path = Resolve-Path -Path "$env:USERPROFILE/.ssh/"
        $docker.volume.Add(@{
            source = $_ssh_path
            target = '/root/.ssh'
        }) | Out-Null

        # remote background will just for remote, local will auto remove
        $docker.rm = $true
        $docker.interactive = $true
        $docker.tty = $true
        if ($background) {
            $docker.env.Add(@{
                name = 'SEED_DETACH'
                value = 'true'
            }) | Out-Null
            $docker.env.Add(@{
                name = 'SEED_INTERACTIVE'
                value = 'false'
            }) | Out-Null
            $docker.env.Add(@{
                name = 'SEED_TTY'
                value = 'false'
            }) | Out-Null
        } else {
            $docker.env.Add(@{
                name = 'SEED_DETACH'
                value = 'false'
            }) | Out-Null
            $docker.env.Add(@{
                name = 'SEED_INTERACTIVE'
                value = 'true'
            }) | Out-Null
            $docker.env.Add(@{
                name = 'SEED_TTY'
                value = 'true'
            }) | Out-Null
        }

        $_playbook_file = Resolve-Path -Path "${_script_dir}/seed-remote.yml"
        $docker.volume.Add(@{
            source = $_playbook_file
            target = '/seed-remote.yml'
        }) | Out-Null

        # remote will replace 'image' 'entrypoint' 'command'
        $docker.entrypoint = 'ansible-playbook'
        $docker.image = $remote_seed_image
        
        if ($PSBoundParameters.Verbose.IsPresent) {
            $_verbose = "-vvvv"
        } elseif ($PSBoundParameters.Debug.IsPresent) {
            $_verbose = "-v"
        } else {
            $_verbose = ""
        }
        $user_host = $ssh -Split '@'
        if ($user_host.Count -eq 2) {
            $_remote_user = $user_host[0]
            $_remote_host = $user_host[1]
        } else {
            $_remote_user = 'root'
            $_remote_host = $user_host[0]
        }
        $docker.command = "$_verbose --extra-vars 'user=$_remote_user host=$_remote_host' /seed-remote.yml"
    }

    $parameters = @{
        Debug = $PSBoundParameters.Debug.IsPresent ? $true : $false
        Verbose = $PSBoundParameters.Verbose.IsPresent ? $true : $false
        docker = $docker
    }
    $dock_cmd = Get-DockerRunCommand @parameters

    Write-Debug $dock_cmd

    Invoke-Expression -Command $dock_cmd
}

function Convert-ImageName {
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [string] $entrypoint
        ,
        [Parameter(Mandatory = $true,Position = 1,ValueFromRemainingArguments = $true)]
        [PSCustomObject[]] $alias
    )
    
    $_img = $alias | Where-Object {
        $_.ContainsKey($entrypoint)
    } | ForEach-Object { 
        $_[$entrypoint]
    } | Select-Object -Last 1

    $_rep_tag = $_img -Split ':'
    if ($_rep_tag.Count -eq 2) {
        $_rep = $_rep_tag[0]
        $_tag = $_rep_tag[1]
    } else {
        $_rep = $_rep_tag[0]
        $_tag = 'latest'
    }

    if ($_rep) {
        return "${_rep}:${_tag}"
    } else {
        Write-Error -Message "Use the '-Image' parameter to force the use of the specified image"
        Write-Error -Message ($entrypoint | Format-Table | Out-String)
        throw "cant find image in 'entrypoint' parameter."
    }
}

function Convert-CommandPath {
    param (
        [Parameter(Mandatory = $true)]
        [string[]] $Command
        ,
        [Parameter()]
        [string] $Workspace
        ,
        [Parameter()]
        [string] $Key
        
    )
    # $path_for_bind = New-Object System.Collections.ArrayList

    $result = $Command | ForEach-Object {
        $ex = Test-Path $_
        if ($ex) {
            $path_src = Resolve-Path $_

            # is in workspace
            $IsWorkspace = $Workspace -and $path_src.Path.StartsWith($Workspace)
            if ($IsWorkspace) {
                $path = $path_src.Path.Substring($Workspace.Length)
                return "\workspace$path" -replace '\\','/'
            }
            # is Key, but not in workspace
            $IsKey = $Key -eq $path_src.Path
            if ($IsKey) {
                return "/root/.ssh/id_rsa"
            }
            
            # if ($IsWindows) {
            # $path_dest = $path_src -replace '\\','/'
            # # WINDOWS convert to /mnt/c/....
            # $path_dest = Invoke-Expression -Command "wsl wslpath $path_dest"
            # } else {
            # $path_dest = $path_src
            # }
            # $path_for_bind.Add(@{
            # src = $path_src
            # dest = $path_dest
            # }) | Out-Null
            # return $path_dest
            Write-Error -Message "Detect file not in 'Workspace' so will not exist in the docker-seed container"
            Write-Error -Message ($path_src | Format-Table | Out-String)
            throw "file out of workspace."
        } else {
            return $_
        }
    }
    return $result
}

function Convert-KeyPath {
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [string] $key
    )
    $key_file = Test-Path "$key"
    if($key_file) {
        return Resolve-Path -Path $Key
    }
    $key_file = Test-Path "$env:USERPROFILE/.ssh/$key/id_rsa"
    if($key_file) {
        return $key
    }
    return $null
}

function Convert-WorkspaceVolume {
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [string] $path
        ,
        [Parameter(Mandatory = $false,Position = 1)]
        [string] $name        
    )
    if (!$name) {
        $name = Split-Path -Leaf $path
    }
    $r = docker volume create --name $name `
            --opt type=none `
            --opt device=$path `
            --opt o=bind `
            --label com.docker.stack.namespace=seed `
            --label com.github.xiaoyao9184.docker-seed.type=workspace `
            --label com.github.xiaoyao9184.docker-seed.creator=docker-seed-cli
            
    
    Write-Debug "Init $name workspace"

    return $r;
}

function Read-WorkspaceParam {
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [string] $Workspace
    )
    $ws_meta_file = Join-Path -Path $Workspace -ChildPath "seed.json"
    
    $ex = Test-Path -Path $ws_meta_file
    if ($ex) {
        return Get-Content $ws_meta_file | ConvertFrom-Json
    }
    
    # deprecated not recommended use
    $ws_meta_path = Join-Path -Path $Workspace -ChildPath ".seed"
    $ws_meta_file = Join-Path -Path $ws_meta_path -ChildPath "seed.json"
    
    $ex = Test-Path -Path $ws_meta_file
    if ($ex) {
        return Get-Content $ws_meta_file | ConvertFrom-Json
    }
}

function Get-WorkspaceByPath {
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [string] $Path
    )
    $seed_path = Join-Path -Path $Path -ChildPath ".seed"
    $seed_include = Test-Path -Path $seed_path
    if($seed_include) {
        return $Path
    } else {
        $parent_path = Split-Path -Path $Path -Parent
        if($parent_path) {
            return Get-WorkspaceByPath -Path $parent_path
        } else {
            return $null
        }
    }
}

function Get-WorkspaceByCommand {
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [string[]] $Command
    )
    Write-Debug -Message "find workspce in:"
    Write-Debug -Message ($Command | Format-Table | Out-String)
    
    # https://stackoverflow.com/questions/31343234/ensure-pipeline-always-results-in-array-without-using
    [Array]$ws_list = $Command | Where-Object {
        Test-Path $_
    } | ForEach-Object {
        $path = Resolve-Path $_
        return Get-WorkspaceByPath $path
    } | Where-Object { $_ } | Select-Object -Unique

    Write-Debug ($ws_list | Format-Table | Out-String)
    if ($null -eq $ws_list -or $ws_list.Length -eq 0) {
        Write-Warning -Message "No workspace found, workspace will not be used."
    } elseif ($ws_list.Length -gt 1) {
        Write-Error -Message "Use the '-Workspace' parameter to force the use of the specified workspace"
        Write-Error -Message ($ws_list | Format-Table | Out-String)
        throw "find multiple workspaces in command parameter."
    } else {
        return $ws_list
    }
}

function Get-KeyName {
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [string] $path
        ,
        [Parameter(Mandatory = $false,Position = 1)]
        [string] $name
    )
    $key_file = Test-Path "$env:USERPROFILE/.ssh/$name/id_rsa"
    if ($key_file) {
        return $name
    }
    $key_hash = Get-FileHash -Path $path -Algorithm MD5
    return $key_hash.Hash
}

function Get-KeyByCommand {
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [string[]] $Command
    )
    Write-Debug -Message "find key in:"
    Write-Debug -Message ($Command | Format-Table | Out-String)

    [Array]$key_list = $Command | Where-Object {
        Test-Path $_ -PathType Leaf
    } | ForEach-Object {
        $path = Resolve-Path $_
        $key_info = Invoke-Expression -Command "ssh-keygen -l -f $path"
        if ($key_info) {
            return $path
        }
    } | Where-Object { $_ } | Select-Object -Unique
    
    Write-Debug ($key_list | Format-Table | Out-String)
    if ($null -eq $key_list -or $key_list.Length -eq 0) {
        Write-Warning -Message "No key found, key will not be used."
    } elseif ($key_list.Length -gt 1) {
        Write-Error -Message "Use the '-Key' parameter to force the use of the specified key"
        Write-Error -Message ($key_list | Format-Table | Out-String)
        throw "find multiple keys in command parameter."
    } else {
        return $key_list
    }
}

function Merge-SeedParam {
    param (
        [Parameter(Mandatory = $false,Position = 0)]
        [PSCustomObject] $seed
        ,
        [Parameter(Mandatory = $false,Position = 1)]
        [hashtable] $target
    )
    if (! $target) {
        $target = @{ 
            docker = @{
                name = $null
                image = $null
                entrypoint = $null
                command = $null
            }
            workspace = @{
                enable = $false
                name = $null
                path = $null
            }
            key = @{
                enable = $false
                path = $null
            }
            entrypoint_image_alias = @{}
        }
    }
    if ($seed) {
        ForEach ($kv in $seed.psobject.properties) {
            ForEach ($kv2 in $kv.Value.psobject.properties) {
                $target[$kv.Name][$kv2.Name] = $kv2.Value 
            }
        }
    }
    return $target
}

function Invoke-SeedDocker {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true,Position = 0)]
        [Alias("e")]
        [ArgumentCompleter({ EntrypointArgumentCompleter @args })]
        [string] $Entrypoint
        ,
        # https://stackoverflow.com/questions/62861665/powershell-pass-all-parameters-received-in-function-and-handle-parameters-with
        [Parameter(Mandatory = $true,Position = 1, ValueFromRemainingArguments = $true)]
        [Alias("c","cmd")]
        [string[]] $Command
        ,
        [Parameter()]
        [Alias("i","img")]
        [ArgumentCompleter({ ImageArgumentCompleter @args })]
        [string] $Image
        ,
        [Parameter()]
        [Alias("w","ws")]
        [ArgumentCompleter({ WorkspaceArgumentCompleter @args })]
        [string] $Workspace
        ,
        [Parameter()]
        [Alias("k","id")]
        [ArgumentCompleter({ KeyArgumentCompleter @args })]
        [string] $Key
        ,
        [Parameter()]
        [Alias("o","on")]
        [ArgumentCompletions('localhost', '192.168.x.x')]
        [string] $RunOn
        ,
        [Parameter()]
        [Alias("b","bg","background")]
        [switch] $RunBackground
        ,
        [Parameter()]
        [Alias("n")]
        [string] $Name
    )
    Begin{}
    Process{
        # Write-Debug (@Args | Format-Table | Out-String)
        Write-Verbose ($PSBoundParameters | Format-Table | Out-String)

        $Seed = Merge-SeedParam

        if (! $Workspace) {
            Write-Debug -Message "try to find workspace by use -Command param"
            $Workspace = Get-WorkspaceByCommand $Command
        }

        if ($Workspace -and $Workspace -ne "NONE") {
            $Workspace = Resolve-Path -Path $Workspace
            
            Write-Debug -Message "try to read seed param from workspace"
            $WorkspaceSeed = Read-WorkspaceParam -Workspace $Workspace
            $Seed = Merge-SeedParam -seed $WorkspaceSeed -target $Seed

            $Seed.workspace.enable = $true
            $Seed.workspace.path = $Workspace
            $Seed.workspace.name = Convert-WorkspaceVolume -path $Workspace -name $Seed.workspace.name
        }

        if (! $Key) {
            Write-Debug -Message "try to find key by use -Command param"
            $Key = Get-KeyByCommand $Command
        }
        if($Key -and $Key -ne "NONE"){
            $Seed.key.path = Convert-KeyPath -key $Key
            $Seed.key.enable = Test-Path -Path $Seed.key.path
            $Seed.key.name = Get-KeyName -path $Seed.key.path -name $Key
        }

        Write-Debug -Message "try to convert command path to relative workspace path"
        $Command = Convert-CommandPath -Command $Command -Workspace $Workspace -Key $Key

        if (! $Image) {
            $Image = Convert-ImageName -entrypoint $Entrypoint `
                -alias $entrypoint_image_alias,$Seed.entrypoint_image_alias
        }
        if ($Image) {
            $Seed.docker.image = $Image
        }

        if(! $RunOn){
            $RunOn = "local"
        }
        if (! $Name) {
            $id = New-Guid
            $Name = "seed-$id"
        }
        
        
        $Seed.docker.name = $Name
        $Seed.docker.entrypoint = $Entrypoint
        $Seed.docker.command = $Command

        $parameters = @{
            Debug = $PSBoundParameters.Debug.IsPresent ? $true : $false
            Verbose = $PSBoundParameters.Verbose.IsPresent ? $true : $false
            ssh = $RunOn
            background = $RunBackground
            seed = $Seed
        }
        Start-SeedDocker @parameters
    }
    End{}
}


function KeyArgumentCompleter {
    param (
        $commandName,
        $parameterName,
        $wordToComplete,
        $commandAst,
        $fakeBoundParameters
    )

    return Get-ChildItem -Path "$env:USERPROFILE/.ssh/" -Directory | Where-Object {
        Test-Path "$_/id_rsa"
    } | ForEach-Object { $_.Name }
}

function WorkspaceArgumentCompleter {
    param (
        $commandName,
        $parameterName,
        $wordToComplete,
        $commandAst,
        $fakeBoundParameters
    )

    $cd = Get-Location
    [Array]$WorkspaceList = Get-Workspace $cd
    return $WorkspaceList
}

function ImageArgumentCompleter {
    param (
        $commandName,
        $parameterName,
        $wordToComplete,
        $commandAst,
        $fakeBoundParameters
    )

    if ($fakeBoundParameters.ContainsKey('Entrypoint')) {
        $entrypoint = $fakeBoundParameters.Entrypoint
        
        if ($entrypoint_image_alias.ContainsKey($entrypoint)) {
            $find_images = $entrypoint_image_alias[$entrypoint]
        }
    } else {
        $find_images = $entrypoint_image_alias.values
    }

    $filter = $find_images | ForEach-Object {
        "--filter '${_}:*'"
    } -join ' '

    $docker_images = Invoke-Expression -ErrorAction Ignore `
        -Command "docker image ls $filter --format '{{.Repository}}:{{.Tag}}'"

    return $docker_images
}

function EntrypointArgumentCompleter {
    param (
        $commandName,
        $parameterName,
        $wordToComplete,
        $commandAst,
        $fakeBoundParameters
    )
    
    if ($fakeBoundParameters.ContainsKey('Image')) {
        $repository_tag = $fakeBoundParameters.Image -Split ':'
        
        $entrypoint_image_alias.Keys | Where-Object {
            $entrypoint_image_alias[$_] -eq $repository_tag[0]
        }
    } else {
        return $entrypoint_image_alias.keys
    }
}

$_script_dir = Split-Path -parent $MyInvocation.MyCommand.Definition
$remote_seed_image = "xiaoyao9184/docker-seed-ansible:latest"
$entrypoint_image_alias = @{
    dockerize = 'xiaoyao9184/docker-seed-dockerize'
    wait4x = 'xiaoyao9184/docker-seed-wait4x'
    ansible = 'xiaoyao9184/docker-seed-ansible'
    'ansible-playbook' = 'xiaoyao9184/docker-seed-ansible'
}

Set-Alias -Name "seed-docker" -Value Invoke-SeedDocker