Functions/Start-AnsibleCtl.ps1

<#
    .SYNOPSIS
        Start the Ansible control container instance.

    .DESCRIPTION
        This function will start a container from the pre-built image which
        already provides all features for mapping the repository, SSH keys and
        so on. The image ghcr.io/claudiospizzi/ansiblectl is available from the
        GitHub Container Registry. Without specifying a different image or
        ansible version, the latest version of the image will be used.

    .EXAMPLE
        PS C:\> Start-AnsibleCtl
        Start the Ansible control container instance in the current directory
        and search $HOME/.ssh for SSH keys.

    .LINK
        https://github.com/claudiospizzi/ansiblectl
#>

function Start-AnsibleCtl
{
    [Alias('ansiblectl')]
    [CmdletBinding(DefaultParameterSetName = 'ContainerImage_KeyFiles')]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'This function provides an interactive experience for the user.')]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '', Justification = 'The 1Password key item is not a password but the item id or name.')]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'This function does not change the system state. It only starts a container instance.')]
    param
    (
        # Path to the Ansible repository. Defaults to the current directory.
        [Parameter(Mandatory = $false, Position = 0)]
        [System.String]
        $RepositoryPath = $PWD.Path,

        # The container image to use.
        [Parameter(Mandatory = $false, ParameterSetName = 'ContainerImage_KeyFiles')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ContainerImage_1Password')]
        [Parameter(Mandatory = $false, ParameterSetName = 'ContainerImage_NoKeys')]
        [System.String]
        $ContainerImage = 'ghcr.io/claudiospizzi/ansiblectl:latest',

        # The Ansible version to use. This will be the container image tag, so
        # semantic versioning is supported of the Ansible community package
        # release version. It has to be a prebuilt image in the container
        # registry ghcr.io/claudiospizzi/ansiblectl.
        [Parameter(Mandatory = $true, ParameterSetName = 'AnsibleVersion_KeyFiles')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AnsibleVersion_1Password')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AnsibleVersion_NoKeys')]
        [System.String]
        $AnsibleVersion,

        # If set, a custom Dockerfile will be used to build the container image
        # before starting the Ansible Control. The base image is controlled in
        # the specified Dockerfile and should be based on any of the official
        # images in ghcr.io/claudiospizzi/ansiblectl.
        [Parameter(Mandatory = $true, ParameterSetName = 'Dockerfile_KeyFiles')]
        [Parameter(Mandatory = $true, ParameterSetName = 'Dockerfile_1Password')]
        [Parameter(Mandatory = $true, ParameterSetName = 'Dockerfile_NoKeys')]
        [System.String]
        $Dockerfile,

        # If set, the local SSH keys in the ~/.ssh directory of the user profile
        # will be mounted into the container.
        [Parameter(Mandatory = $false, ParameterSetName = 'ContainerImage_KeyFiles')]
        [Parameter(Mandatory = $false, ParameterSetName = 'AnsibleVersion_KeyFiles')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Dockerfile_KeyFiles')]
        [System.String]
        $SshKeysFilePath = "$HOME/.ssh",

        # If set, the 1Password key items will be used. The item can be
        # specified by id or by name. All specified keys are mounted into the
        # container.
        [Parameter(Mandatory = $true, ParameterSetName = 'ContainerImage_1Password')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AnsibleVersion_1Password')]
        [Parameter(Mandatory = $true, ParameterSetName = 'Dockerfile_1Password')]
        [System.String[]]
        $SshKeys1Password,

        # If set, no SSH keys will be mounted into the container. This is useful
        # if Ansible is used only for the local system or cloud services which
        # don't required any SSH keys
        [Parameter(Mandatory = $true, ParameterSetName = 'ContainerImage_NoKeys')]
        [Parameter(Mandatory = $true, ParameterSetName = 'AnsibleVersion_NoKeys')]
        [Parameter(Mandatory = $true, ParameterSetName = 'Dockerfile_NoKeys')]
        [Switch]
        $NoSshKeys,

        # Flag to hide the output header before starting the container.
        [Parameter(Mandatory = $false)]
        [Switch]
        $Silent
    )

    try
    {
        ##
        ## Environment Validation
        ##

        Write-Verbose "[ansiblectl] Version $Script:PSModuleVersion"

        if (-not $Silent.IsPresent -and $VerbosePreference -eq 'SilentlyContinue')
        {
            Write-Host "> Verify input and prepare .ansiblectl cache folder... `r" -NoNewline
        }

        # Ensure we are on a Windows system by using the legacy Windows
        # PowerShell or the modern cross-platform PowerShell on the Windows OS.
        if ($PSVersionTable.PSVersion.Major -gt 5 -and -not $IsWindows)
        {
            throw 'The ansiblectl module was designed to run on Windows. Linux and MacOS are not supported.'
        }

        # Check if the Docker Desktop is installed and if it's actually running.
        if ($null -eq (Get-Command -Name 'docker.exe' -CommandType 'Application' -ErrorAction 'SilentlyContinue'))
        {
            throw 'The Docker executable docker.exe was not found in the path. Ensure Docker Desktop is installed.'
        }
        if ($null -eq (Get-Process -Name 'Docker Desktop' -ErrorAction 'SilentlyContinue'))
        {
            throw 'The Docker Desktop process was not found. Ensure Docker Desktop is installed and started.'
        }

        # Check if the specified repository contains an Ansible inventory file
        # called ansible.cfg.
        if (-not (Test-Path -Path $RepositoryPath))
        {
            throw 'The specified Ansible repository does not exist (folder not found).'
        }
        if (-not (Test-Path -Path (Join-Path -Path $RepositoryPath -ChildPath 'ansible.cfg')))
        {
            throw 'The specified Ansible repository does not exist (ansible.cfg not found).'
        }


        ##
        ## Repository Path & Cache (.ansiblectl)
        ##

        Write-Verbose "[ansiblectl] [Repository] Input Path: $RepositoryPath"

        # Resolve the repository path to the full path, so we can use it without
        # relative path issues.
        $repositoryFullPath = Resolve-Path -Path $RepositoryPath | Select-Object -First 1 -ExpandProperty 'Path'
        Write-Verbose "[ansiblectl] [Repository] Resolved Path: $repositoryFullPath"

        # We use a path in the repository to cache ansiblectl related files,
        # this is the .ansiblectl folder.
        $repositoryCachePath = Join-Path -Path $repositoryFullPath -ChildPath '.ansiblectl'
        Write-Verbose "[ansiblectl] [Repository] Verify Cache Path: $repositoryCachePath"
        if (-not (Test-Path -Path $repositoryCachePath))
        {
            New-Item -Path $repositoryCachePath -ItemType 'Directory' | Out-Null
        }

        # This folder will contain the SSH keys to be mounted into the
        # container. They will be deleted as soon as the container starts. We
        # have a background task and the finally block to ensure this folder is
        # always cleaned up. It's also cleaned up before the ansiblectl starts
        # to be sure no old keys are used.
        $repositorySshPath = Join-Path -Path $repositoryFullPath -ChildPath '.ansiblectl/.ssh'
        Write-Verbose "[ansiblectl] [Repository] Verify SSH Folder: $repositorySshPath"
        if (-not (Test-Path -Path $repositorySshPath))
        {
            New-Item -Path $repositorySshPath -ItemType 'Directory' | Out-Null
        }
        else
        {
            Get-ChildItem -Path $repositorySshPath -Filter 'id_*' -File | ForEach-Object { Write-Warning "SSH Keys found in ansiblectl managed folder: $_" }
        }

        # Check if there is a note file in the .ssh path to inform the user that
        # this folder is managed by ansiblectl and cleaned up automatically.
        $repositorySshUserInfoPath = Join-Path -Path $repositoryFullPath -ChildPath '.ansiblectl/.ssh/DO-NOT-SAVE-SSH-KEYS-IN-THIS-FOLDER.txt'
        Write-Verbose "[ansiblectl] [Repository] Verify SSH Key User Info File: $repositorySshUserInfoPath"
        if (-not (Test-Path -Path $repositorySshUserInfoPath))
        {
            Set-Content -Path $repositorySshUserInfoPath -Value 'IMPORTANT NOTE', '**************', '', 'This folder is managed by ansiblectl. All SSH key files are cleaned up', 'automatically. Do not use this folder as your personal SSH key files storage.' -Encoding 'UTF8'
        }

        # Check if there is a .gitignore file in the .ssh path to ensure, that
        # no ssh key is checked into any git repository.
        $repositorySshGitIgnorePath = Join-Path -Path $repositoryFullPath -ChildPath '.ansiblectl/.ssh/.gitignore'
        Write-Verbose "[ansiblectl] [Repository] Verify SSH .gitignore File: $repositorySshGitIgnorePath"
        if (-not (Test-Path -Path $repositorySshGitIgnorePath))
        {
            Set-Content -Path $repositorySshGitIgnorePath -Value '# Ignore all files in the folder', '*' -Encoding 'UTF8'
        }

        # Store the bash history to have the past command for the repository
        # available over multiple runs.
        $repositoryBashHistoryPath = Join-Path -Path $repositoryFullPath -ChildPath '.ansiblectl/.bash_history'
        Write-Verbose "[ansiblectl] [Repository] Verify Bash History File: $repositoryBashHistoryPath"
        if (-not (Test-Path -Path $repositoryBashHistoryPath))
        {
            New-Item -Path $repositoryBashHistoryPath -ItemType 'File' | Out-Null
        }


        ##
        ## Container Image
        ##

        if (-not $Silent.IsPresent -and $VerbosePreference -eq 'SilentlyContinue')
        {
            Write-Host "> Define container image for the ansiblectl container... `r" -NoNewline
        }

        if ($PSCmdlet.ParameterSetName -like 'ContainerImage_*')
        {
            # Nothing to do, the container image is already specified.
            Write-Verbose "[ansiblectl] [Container Image] Image: $ContainerImage"
        }

        if ($PSCmdlet.ParameterSetName -like 'AnsibleVersion_*')
        {
            Write-Verbose "[ansiblectl] [Container Image] Ansible Version: $AnsibleVersion"

            $ContainerImage = "ghcr.io/claudiospizzi/ansiblectl:$AnsibleVersion"

            Write-Verbose "[ansiblectl] [Container Image] Image: $ContainerImage"
        }

        if ($PSCmdlet.ParameterSetName -like 'Dockerfile_*')
        {
            Write-Verbose "[ansiblectl] [Container Image] Custom Dockerfile: $Dockerfile"

            # Check if the specified Dockerfile actually exists.
            if (-not (Test-Path -Path $Dockerfile))
            {
                throw "The specified Dockerfile '$Dockerfile' does not exist."
            }

            # Prepare a container image name based on the Dockerfile hash.
            $dockerfileHash = Get-FileHash -Path $Dockerfile -Algorithm 'SHA256' | ForEach-Object { $_.Hash.ToLower().Substring(0, 12) }
            $dockerfilePath = Split-Path -Path $Dockerfile -Parent
            $ContainerImage = 'localhost/ansiblectl:{0}' -f $dockerfileHash

            Write-Verbose "[ansiblectl] [Container Image] Image: $ContainerImage (to be built from Dockerfile)"
        }


        ##
        ## SSH Keys
        ##

        if (-not $Silent.IsPresent -and $VerbosePreference -eq 'SilentlyContinue')
        {
            Write-Host "> Prepare SSH Keys for the ansiblectl container... `r" -NoNewline
        }

        $sshKeysCleanupFiles = @()

        if ($PSCmdlet.ParameterSetName -like '*_KeyFiles')
        {
            # Use the local SSH keys in the ~/.ssh directory.
            if (-not (Test-Path -Path $SshKeysFilePath))
            {
                throw "The specified SSH key directory '$SshKeysFilePath' does not exist."
            }

            Write-Verbose "[ansiblectl] [SSH Keys] Using local SSH keys: $SshKeysFilePath"

            # Ensure we have some SSH keys
            $sshKeyFiles = Get-ChildItem -Path $SshKeysFilePath -Filter 'id_*' -File
            if ($null -eq $sshKeyFiles)
            {
                throw "No specified SSH key files found in the specified directory '$SshKeysFilePath'."
            }

            # Copy all files from the local SSH directory to the repository
            # cache SSH directory.
            foreach ($sshKeyFile in $sshKeyFiles)
            {
                $sshKeyTempFile = Join-Path -Path $repositorySshPath -ChildPath $sshKeyFile.Name

                Write-Verbose "[ansiblectl] [SSH Keys] Copying local SSH key: $($sshKeyFile.FullName) -> $sshKeyTempFile"

                Copy-Item -Path $sshKeyFile.FullName -Destination $sshKeyTempFile -Force

                $sshKeysCleanupFiles += $sshKeyTempFile
            }

            $sshKeyMode = 'Key Files ({0})' -f $SshKeysFilePath
        }

        if ($PSCmdlet.ParameterSetName -like '*_1Password')
        {
            # Check if 1Password is actually running.
            if ($null -eq (Get-Process -Name '1password' -ErrorAction 'SilentlyContinue'))
            {
                throw 'The 1Password process was not found. Ensure 1Password is installed and started.'
            }

            # Check if the 1Password CLI is installed.
            if ($null -eq (Get-Command -Name 'op.exe' -CommandType 'Application' -ErrorAction 'SilentlyContinue'))
            {
                throw 'The 1Password executable op.exe was not found in the path. Ensure 1Password CLI is installed.'
            }

            Write-Verbose "[ansiblectl] [SSH Keys] Using 1Password SSH keys: $($SshKeys1Password -join ', ')"

            # Ensure we have some SSH keys
            $sshKeyItems = op.exe item list --categories "SSH Key" --format 'json' | ConvertFrom-Json |
                Where-Object { $_.id -in $SshKeys1Password -or $_.title -in $SshKeys1Password }
            if ($null -eq $sshKeyItems)
            {
                throw 'No SSH key items found in 1Password which match the specified key id or name. Unable to mount the SSH keys into the Ansible control node.'
            }

            # Export all items to the to the repository cache SSH directory.
            foreach ($sshKeyItem in $sshKeyItems)
            {
                # Generate objects with the properties:
                # - Name : The file name to use (id_<item id> and id_<item id>.pub)
                # - Value : The content of the file (public or private key)
                Write-Verbose "[ansiblectl] [SSH Keys] Exporting 1Password SSH key: $($sshKeyItem.title)"
                $sshKeyFiles = op.exe item get $sshKeyItem.id --fields "label=public key,label=private key" --format json |
                    ConvertFrom-Json |
                        ForEach-Object {
                            $nameTemplate = 'id_{0}'
                            if ($_.label -eq 'public key')
                            {
                                $nameTemplate += '.pub'
                            }
                            [PSCustomObject] @{
                                Name = $nameTemplate -f $sshKeyItem.id
                                Value = $_.value
                            }
                        }

                foreach ($sshKeyFile in $sshKeyFiles)
                {
                    $sshKeyTempFile = Join-Path -Path $repositorySshPath -ChildPath $sshKeyFile.Name
                    Write-Verbose "[ansiblectl] [SSH Keys] Store 1Password SSH key file: $sshKeyTempFile"
                    Set-Content -Path $sshKeyTempFile -Value $sshKeyFile.Value -Encoding 'UTF8'
                    $sshKeysCleanupFiles += $sshKeyTempFile
                }
            }

            $sshKeyMode = '1Password Items ({0})' -f ($SshKeys1Password -join ', ')
        }

        if ($PSCmdlet.ParameterSetName -like '*_NoKeys')
        {
            Write-Verbose '[ansiblectl] [SSH Keys] No SSH keys will be used.'

            if (-not $NoSshKeys.IsPresent)
            {
                throw 'Setting the -NoSshKeys switch to false is not supported. Please set it to true (or just use the switch). As an alternative use the -SshKeyFilePath or -OnePasswordSshKeys parameters to specify SSH keys.'
            }

            $sshKeyMode = 'Disabled'
        }


        ##
        ## Run Ansible Control Node
        ##

        $normalizedRepositoryPath = '/{0}' -f $repositoryFullPath.Replace(':', '').Replace('\', '/').Trim('/')

        $dockerSshKeysVolumeMount        = '{0}/.ansiblectl/.ssh:/tmp/.ssh' -f $normalizedRepositoryPath
        $dockerBashHistoryVolumeMount    = '{0}/.ansiblectl/.bash_history:/root/.bash_history' -f $normalizedRepositoryPath
        $dockerRepositoryPathVolumeMount = '{0}:/ansible' -f $normalizedRepositoryPath

        if ($PSCmdlet.ParameterSetName -like 'Dockerfile_*')
        {
            if (-not $Silent.IsPresent -and $VerbosePreference -eq 'SilentlyContinue')
            {
                Write-Host "> Building container image from specified Dockerfile... `r" -NoNewline
            }
            Invoke-DockerProcess -Command 'build' -ArgumentList '-t', $ContainerImage, '-f', $Dockerfile, $dockerfilePath -ErrorMessage "The Docker build of the Dockerfile '$Dockerfile' failed."
        }
        else
        {
            if (-not $Silent.IsPresent -and $VerbosePreference -eq 'SilentlyContinue')
            {
                Write-Host "> Pulling container image from remote registry... `r" -NoNewline
            }
            Invoke-DockerProcess -Command 'pull' -ArgumentList $ContainerImage -ErrorMessage "The Docker pull of the container image '$ContainerImage' failed."
        }

        # Create a clean-up job to remove the key files after 15 seconds
        Start-Job -ScriptBlock { Start-Sleep -Seconds 15; Get-ChildItem -Path $using:repositorySshPath -Filter 'id_*' -File | Remove-Item -Force } | Out-Null

        if (-not $Silent.IsPresent)
        {
            Write-Host ' '
            Write-Host 'ANSIBLE CONTROL NODE' -ForegroundColor 'Magenta'
            Write-Host '********************' -ForegroundColor 'Magenta'
            Write-Host ''
            Write-Host "Ansible Repo : $repositoryFullPath"
            Write-Host "Container Image : $ContainerImage"
            Write-Host "SSH Key Mode : $sshKeyMode"
            Write-Host ''
        }

        Invoke-DockerProcess -Command 'run' -ArgumentList '-it', '--rm', '-h', 'ansiblectl', '-v', $dockerSshKeysVolumeMount, '-v', $dockerRepositoryPathVolumeMount, '-v', $dockerBashHistoryVolumeMount, $ContainerImage -ShowOutput
    }
    catch
    {
        $PSCmdlet.ThrowTerminatingError($_)
    }
    finally
    {
        # Ensure no key files are stored in the repository after running this command
        foreach ($sshKeysCleanupFile in $sshKeysCleanupFiles)
        {
            if (Test-Path -Path $sshKeysCleanupFile)
            {
                Remove-Item -Path $sshKeysCleanupFile -Force
            }
        }
    }
}

# List all available ansible versions (image tags) from the GitHub Container Registry
Register-ArgumentCompleter -CommandName 'Start-AnsibleCtl' -ParameterName 'AnsibleVersion' -ScriptBlock {
    param ($CommandName, $ParameterName, $WordToComplete, $CommandAst, $FakeBoundParameters)

    # Hardcode the 'latest' tag as it's always available
    $results = @('latest')

    # Query the config.json from the GitHub repository to get the available
    # Ansible versions which will be builded and published to the container.
    # This is not perfect, but the image registry has non anonymous API to get
    # all containers tags.
    $config = Invoke-RestMethod -uri 'https://raw.githubusercontent.com/claudiospizzi/ansiblectl/refs/heads/main/docker/config.json'
    foreach ($ansibleVersion in $config.ansible.versions) {
        $results += $ansibleVersion
        $results += "$ansibleVersion-ci"
    }

    foreach ($result in $results)
    {
        if ($result -like "$WordToComplete*")
        {
            [System.Management.Automation.CompletionResult]::new($result, $result, 'ParameterValue', $result)
        }
    }
}