<#
validar-imagens-docker.ps1

Script de execucao manual. Nao deve ser chamado automaticamente durante a implantacao.

Lista as imagens Docker LOCAIS e valida, pelo digest/hash, se a tag local
esta atualizada em relacao ao registro remoto.

Exemplo:
  .\validar-imagens-docker.ps1 -Config "C:\microuniversoWeb\microuniversoWeb.yaml" -Login

Com pull automatico das desatualizadas:
  .\validar-imagens-docker.ps1 -Config "C:\microuniversoWeb\microuniversoWeb.yaml" -Login -Pull

Filtrando:
  .\validar-imagens-docker.ps1 -Filtro "mufenix|extranet"

Imagem especifica:
  .\validar-imagens-docker.ps1 -Imagem "gitlab.microuniverso.com.br:5050/projeto/imagem:latest"

JSON:
  .\validar-imagens-docker.ps1 -Json
#>

param(
    [string]$Config = "",
    [string]$Registro = "gitlab.microuniverso.com.br:5050",

    [switch]$Login,
    [switch]$Pull,
    [switch]$IncluirPublicas,
    [switch]$Json,

    [string]$Filtro = "",
    [string[]]$Imagem = @()
)

Set-StrictMode -Version Latest

# ---------------------------------------------------------------------------
# Utilitarios
# ---------------------------------------------------------------------------

function Write-Info {
    param([string]$Message)

    if (-not $Json) {
        [Console]::Error.WriteLine($Message)
    }
}

function Write-Alert {
    param([string]$Message)

    if (-not $Json) {
        [Console]::Error.WriteLine($Message)
    }
}

function Write-Fatal {
    param([string]$Message)

    [Console]::Error.WriteLine($Message)
    exit 2
}

function Short-Sha {
    param([string]$Digest)

    if ([string]::IsNullOrWhiteSpace($Digest)) {
        return "-"
    }

    $value = $Digest -replace "^sha256:", ""

    if ($value.Length -le 12) {
        return $value
    }

    return $value.Substring(0, 12)
}

function Split-ImageRef {
    param([string]$Ref)

    if ([string]::IsNullOrWhiteSpace($Ref)) {
        return [pscustomobject]@{
            Nome   = ""
            Versao = "-"
        }
    }

    $lastSlash = $Ref.LastIndexOf("/")
    $lastColon = $Ref.LastIndexOf(":")

    if ($lastColon -gt $lastSlash) {
        return [pscustomobject]@{
            Nome   = $Ref.Substring(0, $lastColon)
            Versao = $Ref.Substring($lastColon + 1)
        }
    }

    return [pscustomobject]@{
        Nome   = $Ref
        Versao = "-"
    }
}

function Remove-YamlQuotes {
    param([string]$Value)

    if ($null -eq $Value) {
        return ""
    }

    $v = $Value.Trim()

    if ($v.Length -ge 2) {
        if (($v.StartsWith('"') -and $v.EndsWith('"')) -or ($v.StartsWith("'") -and $v.EndsWith("'"))) {
            return $v.Substring(1, $v.Length - 2)
        }
    }

    return $v
}

function Get-SimpleYamlValue {
    param(
        [string]$Path,
        [string]$Key
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        return ""
    }

    $target = $Key
    $levels = @{}

    $lines = Get-Content -LiteralPath $Path -Encoding UTF8

    foreach ($line in $lines) {
        if ([string]::IsNullOrWhiteSpace($line)) {
            continue
        }

        if ($line.TrimStart().StartsWith("#")) {
            continue
        }

        if ($line -notmatch '^(\s*)([^:#]+?)\s*:\s*(.*)$') {
            continue
        }

        $indent = $matches[1].Length
        $keyName = $matches[2].Trim().Trim('"').Trim("'")
        $rawValue = $matches[3].Trim()

        foreach ($existingIndent in @($levels.Keys)) {
            if ([int]$existingIndent -gt $indent) {
                $levels.Remove($existingIndent)
            }
        }

        $levels[$indent] = $keyName

        $currentPath = (
            $levels.Keys |
                Sort-Object {[int]$_} |
                ForEach-Object { $levels[$_] }
        ) -join "."

        if ($currentPath -eq $target) {
            return Remove-YamlQuotes $rawValue
        }
    }

    return ""
}

function Test-DockerCommand {
    if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
        Write-Fatal "docker nao encontrado no PATH. Instale Docker Desktop ou disponibilize docker.exe no PowerShell."
    }

    & docker version *> $null
    if ($LASTEXITCODE -ne 0) {
        Write-Fatal "Docker nao respondeu. Verifique se o Docker Desktop/daemon esta em execucao."
    }

    & docker buildx version *> $null
    if ($LASTEXITCODE -ne 0) {
        Write-Fatal "docker buildx nao disponivel. Ele e necessario para consultar o digest remoto sem baixar a imagem."
    }
}

function Get-LocalDigest {
    param([string]$Ref)

    $repo = $Ref

    if ($Ref -match '^(.*):[^/:]+$') {
        $repo = $matches[1]
    }

    $repoDigests = & docker image inspect $Ref --format '{{range .RepoDigests}}{{println .}}{{end}}' 2>$null

    if ($LASTEXITCODE -ne 0) {
        return [pscustomobject]@{
            Code   = "AUSENTE"
            Digest = ""
        }
    }

    if ($null -eq $repoDigests -or [string]::IsNullOrWhiteSpace(($repoDigests -join "`n"))) {
        return [pscustomobject]@{
            Code   = "SEM_DIGESTO"
            Digest = ""
        }
    }

    foreach ($line in $repoDigests) {
        if ([string]::IsNullOrWhiteSpace($line)) {
            continue
        }

        $parts = $line -split "@", 2

        if ($parts.Count -eq 2 -and $parts[0] -eq $repo) {
            return [pscustomobject]@{
                Code   = "OK"
                Digest = $parts[1]
            }
        }
    }

    $first = ($repoDigests | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1)

    if ($first -and $first.Contains("@")) {
        return [pscustomobject]@{
            Code   = "OK"
            Digest = (($first -split "@", 2)[1])
        }
    }

    return [pscustomobject]@{
        Code   = "SEM_DIGESTO"
        Digest = ""
    }
}

function Get-LocalImageId {
    param([string]$Ref)

    $id = & docker image inspect $Ref --format '{{.Id}}' 2>$null

    if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($id)) {
        return ""
    }

    return ($id | Select-Object -First 1).Trim()
}

function Get-RemoteDigest {
    param([string]$Ref)

    $tmp = [System.IO.Path]::GetTempFileName()

    try {
        $digest = & docker buildx imagetools inspect $Ref --format '{{.Manifest.Digest}}' 2>$tmp
        $rc = $LASTEXITCODE
        $err = ""

        if (Test-Path -LiteralPath $tmp) {
            $err = Get-Content -LiteralPath $tmp -Raw -ErrorAction SilentlyContinue
        }

        $digestText = ""

        if ($null -ne $digest) {
            $digestText = (($digest | Select-Object -First 1) -as [string]).Trim()
        }

        if ($rc -ne 0 -or [string]::IsNullOrWhiteSpace($digestText) -or $digestText -eq "<no value>") {
            $firstError = ""

            if (-not [string]::IsNullOrWhiteSpace($err)) {
                $firstError = (($err -split "`r?`n") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1)
            }

            $authError = $false

            if ($firstError -match '(?i)unauthor|denied|authorize|forbidden|401|403|login') {
                $authError = $true
            }

            return [pscustomobject]@{
                Ok        = $false
                Digest    = ""
                Error     = $firstError
                AuthError = $authError
            }
        }

        return [pscustomobject]@{
            Ok        = $true
            Digest    = $digestText
            Error     = ""
            AuthError = $false
        }
    }
    finally {
        Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
    }
}

function Get-ContainerImageMap {
    param([switch]$All)

    $map = @{}

    if ($All) {
        $containerIds = & docker ps -aq 2>$null
    }
    else {
        $containerIds = & docker ps -q 2>$null
    }

    if ($LASTEXITCODE -ne 0 -or $null -eq $containerIds) {
        return $map
    }

    $containerIds = @($containerIds | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })

    if ($containerIds.Count -eq 0) {
        return $map
    }

    $imageIds = & docker inspect -f '{{.Image}}' @containerIds 2>$null

    if ($LASTEXITCODE -ne 0 -or $null -eq $imageIds) {
        return $map
    }

    foreach ($id in $imageIds) {
        if (-not [string]::IsNullOrWhiteSpace($id)) {
            $map[$id.Trim()] = $true
        }
    }

    return $map
}

# ---------------------------------------------------------------------------
# Pre-requisitos
# ---------------------------------------------------------------------------

Test-DockerCommand

# ---------------------------------------------------------------------------
# Login no registro, se solicitado
# ---------------------------------------------------------------------------

$usuarioRegistro = ""
$senhaRegistro = ""

if (-not [string]::IsNullOrWhiteSpace($Config)) {
    if (-not (Test-Path -LiteralPath $Config)) {
        Write-Fatal "Arquivo de configuracao nao encontrado: $Config"
    }

    $usuarioRegistro = Get-SimpleYamlValue -Path $Config -Key "acessos.registroDocker.usuario"
    $senhaRegistro = Get-SimpleYamlValue -Path $Config -Key "acessos.registroDocker.senha"
}

if ($Login) {
    if ([string]::IsNullOrWhiteSpace($usuarioRegistro) -or [string]::IsNullOrWhiteSpace($senhaRegistro)) {
        Write-Fatal "-Login requer -Config com acessos.registroDocker.usuario e acessos.registroDocker.senha."
    }

    Write-Info "Fazendo docker login em $Registro ..."

    $senhaRegistro | docker login $Registro -u $usuarioRegistro --password-stdin *> $null

    if ($LASTEXITCODE -ne 0) {
        Write-Alert "Falha no docker login em $Registro. A consulta ao registro pode falhar."
    }
}

# ---------------------------------------------------------------------------
# Monta lista de imagens
# ---------------------------------------------------------------------------

$imagens = @()

if ($Imagem -and $Imagem.Count -gt 0) {
    $imagens = @($Imagem)
}
else {
    $listaDocker = & docker images --format '{{.Repository}}:{{.Tag}}' 2>$null

    if ($LASTEXITCODE -ne 0) {
        Write-Fatal "Falha ao listar imagens locais com docker images."
    }

    foreach ($ref in $listaDocker) {
        if ([string]::IsNullOrWhiteSpace($ref)) {
            continue
        }

        if ($ref -like "*<none>*") {
            continue
        }

        if (-not $IncluirPublicas -and -not $ref.StartsWith($Registro)) {
            continue
        }

        if (-not [string]::IsNullOrWhiteSpace($Filtro) -and $ref -notmatch $Filtro) {
            continue
        }

        $imagens += $ref
    }

    $imagens = @($imagens | Sort-Object -Unique)
}

if ($imagens.Count -eq 0) {
    Write-Alert "Nenhuma imagem local encontrada para validar."

    if (-not $IncluirPublicas) {
        Write-Alert "Dica: use -IncluirPublicas para imagens fora de $Registro."
    }

    exit 2
}

# ---------------------------------------------------------------------------
# Mapa de imagens em uso por containers
# ---------------------------------------------------------------------------

$usoRun = Get-ContainerImageMap
$usoAll = Get-ContainerImageMap -All

# ---------------------------------------------------------------------------
# Validacao
# ---------------------------------------------------------------------------

$nOk = 0
$nDesatualizada = 0
$nSemDigesto = 0
$nErro = 0
$nAusente = 0
$houveAuth = $false
$paraPull = @()
$resultadosJson = @()
$larguraVersao = 22

foreach ($ref in $imagens) {
    $imagemInfoLargura = Split-ImageRef -Ref $ref
    $larguraVersao = [Math]::Max($larguraVersao, $imagemInfoLargura.Versao.Length + 2)
}

$formatoTabela = "{0,-$larguraVersao} {1,-14} {2,-7} {3,-13} {4,-13} {5}"

if (-not $Json) {
    Write-Info ""
    Write-Info "Validando $($imagens.Count) imagem(ns) local(is) contra o registro..."
    Write-Info ""
    Write-Info ($formatoTabela -f "VERSAO", "STATUS", "USO", "SHA LOCAL", "SHA REMOTO", "IMAGEM")
}

foreach ($ref in $imagens) {
    $local = Get-LocalDigest -Ref $ref
    $remote = Get-RemoteDigest -Ref $ref
    $imagemInfo = Split-ImageRef -Ref $ref

    if ($remote.AuthError) {
        $houveAuth = $true
    }

    $status = ""

    if ($local.Code -eq "AUSENTE") {
        $status = "AUSENTE"
    }
    elseif (-not $remote.Ok) {
        $status = "ERRO"
    }
    elseif ($local.Code -eq "SEM_DIGESTO" -or [string]::IsNullOrWhiteSpace($local.Digest)) {
        $status = "SEM_DIGESTO"
    }
    elseif ($local.Digest -eq $remote.Digest) {
        $status = "ATUALIZADA"
    }
    else {
        $status = "DESATUALIZADA"
    }

    $imageId = Get-LocalImageId -Ref $ref

    if (-not [string]::IsNullOrWhiteSpace($imageId) -and $usoRun.ContainsKey($imageId)) {
        $uso = "sim"
    }
    elseif (-not [string]::IsNullOrWhiteSpace($imageId) -and $usoAll.ContainsKey($imageId)) {
        $uso = "parado"
    }
    else {
        $uso = "nao"
    }

    switch ($status) {
        "ATUALIZADA" {
            $nOk++
        }
        "DESATUALIZADA" {
            $nDesatualizada++
            $paraPull += $ref
        }
        "SEM_DIGESTO" {
            $nSemDigesto++
        }
        "AUSENTE" {
            $nAusente++
            $paraPull += $ref
        }
        "ERRO" {
            $nErro++
        }
    }

    $shaLocal = Short-Sha $local.Digest
    $shaRemoto = Short-Sha $remote.Digest

    if ($Json) {
        $resultadosJson += [pscustomobject]@{
            versao            = $imagemInfo.Versao
            status            = $status
            em_uso            = $uso
            digest_local      = $local.Digest
            digest_remoto     = $remote.Digest
            imagem            = $ref
            imagem_sem_versao = $imagemInfo.Nome
            erro_remoto       = $remote.Error
        }
    }
    else {
        $linha = $formatoTabela -f $imagemInfo.Versao, $status, $uso, $shaLocal, $shaRemoto, $imagemInfo.Nome

        switch ($status) {
            "ATUALIZADA" {
                Write-Host $linha -ForegroundColor Green
            }
            "DESATUALIZADA" {
                Write-Host $linha -ForegroundColor Red
            }
            "SEM_DIGESTO" {
                Write-Host $linha -ForegroundColor Yellow
            }
            "AUSENTE" {
                Write-Host $linha -ForegroundColor Yellow
            }
            "ERRO" {
                Write-Host $linha -ForegroundColor Red
            }
            default {
                Write-Host $linha
            }
        }

        if ($status -eq "ERRO" -and -not [string]::IsNullOrWhiteSpace($remote.Error)) {
            Write-Info "   motivo: $($remote.Error)"
        }
    }
}

# ---------------------------------------------------------------------------
# Saida JSON
# ---------------------------------------------------------------------------

if ($Json) {
    $resultadosJson | ConvertTo-Json -Depth 5
}

# ---------------------------------------------------------------------------
# Pull das desatualizadas/ausentes
# ---------------------------------------------------------------------------

if ($Pull -and $paraPull.Count -gt 0) {
    Write-Info ""
    Write-Info "Atualizando $($paraPull.Count) imagem(ns) com docker pull..."

    foreach ($ref in $paraPull) {
        Write-Info "  docker pull $ref"

        & docker pull $ref

        if ($LASTEXITCODE -ne 0) {
            Write-Alert "  Falha ao baixar $ref"
        }
    }
}

# ---------------------------------------------------------------------------
# Resumo
# ---------------------------------------------------------------------------

if (-not $Json) {
    Write-Info ""
    Write-Info "Resumo:"
    Write-Info "  Atualizadas   : $nOk"
    Write-Info "  Desatualizadas: $nDesatualizada"
    Write-Info "  Sem digesto   : $nSemDigesto"

    if ($nAusente -gt 0) {
        Write-Info "  Ausentes      : $nAusente"
    }

    Write-Info "  Erros         : $nErro"

    if ($houveAuth) {
        Write-Info ""
        Write-Alert "Parece falta de autenticacao no registro. Rode com login, exemplo:"
        Write-Alert "  .\validar-imagens-docker.ps1 -Config `"C:\microuniversoWeb\microuniversoWeb.yaml`" -Login"
        Write-Alert "ou faca docker login $Registro manualmente antes."
    }
}

# Codigo de saida:
# 0 = tudo atualizado
# 1 = ha desatualizadas, ausentes ou erros
# 2 = erro de uso/pre-requisito

if ($nDesatualizada -gt 0 -or $nAusente -gt 0 -or $nErro -gt 0) {
    exit 1
}

exit 0
