Simpleverse.Bicep.psm1

using namespace System.Management.Automation
enum LogLevel
{
    Verbose
    Debug
    Information
    Warning
    Error
}

function Format-LogMessage {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [Object] $Message,
        [Parameter(Mandatory = $true, Position = 1)]
        [Alias("l")]
        [LogLevel] $Level
    )
    BEGIN
    {}
    PROCESS
    {
        if ($level -eq [LogLevel]::Debug) {
            return "[$(Get-Date -format "yyyy-MM-dd HH:mm:ss.fff")] $($Message)"
        }

        switch ($Level) {
            Verbose { $prefix = 'VERBOSE' }
            Debug { $prefix = '' }
            Information { $prefix = 'INFO' }
            Warning { $prefix = 'WARNING' }
            Error { $prefix = 'ERROR' }
        }

        return "$($prefix): [$(Get-Date -format "yyyy-MM-dd HH:mm:ss.fff")] $($Message)"
    }
    END
    {}
}
function Format-Message {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [Object] $Message,
        [Parameter(Mandatory = $false)]
        [Nullable[System.Int32]] $Index
    )
    BEGIN
    {}
    PROCESS
    {
        if ($null -eq $Index) {
            return "$($Message)"
        } else {
            return "[#$($index)] $($message)"
        }
    }
    END
    {}
}
function Write-DebugEx {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [Object] $Message
    )
    BEGIN
    {}
    PROCESS
    {
        if ($null -eq $Message -or $Message -eq '') {
            return
        }

        Format-LogMessage -Message $Message -Level Debug | Write-Debug
    }
    END
    {}
}
function Write-InformationEx
{
<#
    .SYNOPSIS
        Writes out a message in defined colors.
 
    .DESCRIPTION
        Enables writing of message in defined colors as with Write-Host, but replaces Write-Host as it is not the prefered way of writing output.
 
    .PARAMETER Message
        Message to writeout.
 
    .PARAMETER Background
        Background color of the text.
 
    .PARAMETER Foreground
        Foreground color of the text.
 
    .PARAMETER NoNewline
        Terminate with an new line or not.
 
    .PARAMETER noOutput
        Do not override InformationAction.
 
    .EXAMPLE
        PS C:\> Write-InformationEx 'Message' -Foreground 'Cyan' -Background 'White' -NoNewline
 
#>

    [CmdletBinding()]
    [OutputType([string])]
    param
    (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [Object]$Message,
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrWhiteSpace()]
        [Alias("b")]
        [System.ConsoleColor[]]$BackgroundColor = $Host.UI.RawUI.BackgroundColor,
        [Parameter(Mandatory = $false)]
        [Alias("f")]
        [ValidateNotNullOrWhiteSpace()]
        [System.ConsoleColor[]]$ForegroundColor = $Host.UI.RawUI.ForegroundColor,
        [Parameter(Mandatory = $false)]
        [Alias("nn")]
        [Switch]$NoNewline,
        [Parameter(Mandatory = $false)]
        [Alias("no")]
        [Switch]$noOutput
    )
    BEGIN
    {}
    PROCESS
    {
        if ($null -eq $Message -or $Message -eq '') {
            return
        }

        [HostInformationMessage]$outMessage = @{
            Message                    = Format-LogMessage -Message $Message -Level Information
            ForegroundColor            = $ForegroundColor
            BackgroundColor            = $BackgroundColor
            NoNewline                = $NoNewline
        }

        if ($noOutput) {
            Write-Information $outMessage
        } else {
            Write-Information $outMessage -InformationAction Continue
        }
    }
    END
    {}
}
class BicepModule {
    [string] $FilePath
    [string] $Name

    BicepModule() {}

    BicepModule([string] $fullPath) {
        $this.FilePath = Resolve-Path -Relative $fullPath
        $this.Name = $this.FilePath -replace "^./" -replace '^.\\' -replace '\..*'
    }

    BicepModule([string] $filePath, [string] $name) {
        FilePath = $filePath
        Name = $name
    }
}

<#
.SYNOPSIS
 
Lists all modules impacted by changes in a defined commit range.
 
.DESCRIPTION
 
The command will output modules that have either been changed in the commit range or modules that have been impacted by the change. The module list will include
 
* modules added, edited or renamed
* modules that import or use modules added, edited or renamed
 
The command preforms the search recursively through all detected module files to build a complete list of impacted modules.
 
.INPUTS
 
None. You cannot pipe objects to Add-Extension.
 
.OUTPUTS
 
None.
 
.EXAMPLE
 
PS> Get-BicepModuleChanged '*.bicep' 'd41eeb1c7c0a6a5e3f11efc175aa36b8eaae4af5..0ee2650f101237af9ad923ad2264d37b983d8bab'
 
.LINK
 
https://github.com/lukaferlez/Simpleverse.Bicep/blob/main/README.md
 
#>

function Get-BicepModuleChanged {
    Param(
        [Parameter(Mandatory=$true,    Position=0, HelpMessage="PathSpec to grep Bicep modules to publish.")]
        [ValidateNotNullOrWhiteSpace()]
        [string] $PathSpec,
        [Parameter(Mandatory=$true,    Position=1, HelpMessage="Commit range to check for changes.")]
        [ValidateNotNullOrWhiteSpace()]
        [string] $CommitRange,
        [Parameter(Mandatory=$false, HelpMessage="Exclude direct changes to files in pathSpec from being published.")]
        [Alias("ed")]
        [switch] $ExcludeDirectChanges
    )

    Write-InformationEx "Get-BicepModuleChanged: $PathSpec - $CommitRange" -ForegroundColor Green

    $changedFiles = git diff-tree --no-commit-id --name-only --diff-filter=d -r $CommitRange $PathSpec
    Write-DebugEx "Found $($changedFiles.Count) changed files."
    $changedFiles | Format-Table | Out-String | Write-DebugEx

    $changedModules = @()
    foreach ($file in $changedFiles) {
        $changedModules += [BicepModule]::new($file)
    }

    Write-InformationEx "Get-BicepModuleChanged: Found $($changedModules.Count) changed modules." -ForegroundColor Green
    $changedModules | Select-Object Name, FilePath | Format-Table | Out-String | Write-InformationEx

    $bicepImports = Get-BicepModuleImport $PathSpec

    function Get-ImpactedModules2 {
        Param(
            [BicepModule] $changedModule,
            $imports
        )
        Write-DebugEx "Get-ImpactedModules: $($changedModule.FilePath) - Resolving impacted modules"

        $impactedModules = @($changedModule)

        $import = $imports | Where-Object { $_.Name -eq $changedModule.FilePath } | Select-Object -First 1
        if ($null -ne $import) {
            Write-DebugEx "Get-BicepModuleChanged: $($changedModule.FilePath) - Discovered dependecies"

            foreach($filePath in $import.FilePaths) {
                Write-DebugEx "Get-BicepModuleChanged: $($changedModule.FilePath) - Resolving impacted modules for $($filePath)"

                $module = [BicepModule]::new($filePath)
                $impactedModules += Get-ImpactedModules2 $module $imports
            }
        }

        return $impactedModules
    }

    $impactedModules = @()
    foreach($changedModule in $changedModules) {
        $impactedModules += Get-ImpactedModules2 $changedModule $bicepImports
    }

    $impactedModules  = $impactedModules | Group-Object -Property 'Name', 'FilePath' | ForEach-Object { $_.Group | Select-Object 'Name', 'FilePath' -First 1 } | Sort-Object 'Name'
    if ($ExcludeDirectChanges) {
        $reducedModules = @()
        foreach ($module in $impactedModules) {
            $existingModule = $changedModules | Where-Object { $_.Name -eq $module.Name }
            if ($null -eq $existingModule) {
                $reducedModules += $module
            }
        }

        $impactedModules = $reducedModules
    }

    Write-InformationEx "Get-BicepModuleChanged: Found $($impactedModules.Count) impacted modules." -ForegroundColor Green
    $impactedModules | Select-Object Name, FilePath | Format-Table | Out-String | Write-InformationEx

    return $impactedModules
}

Export-ModuleMember Get-ChangedBicepModule
function Get-BicepModuleForPublish {
    Param(
        [Parameter(Mandatory=$true,    Position=0, HelpMessage="PathSpec to grep Bicep modules to publish.")]
        [ValidateNotNullOrWhiteSpace()]
        [string] $PathSpec,
        [Parameter(Mandatory=$false, HelpMessage="Commit range to check for changes. If not supplied will return all files in pathSpec.")]
        [Alias("cr")]
        [string] $CommitRange,
        [Parameter(Mandatory=$false, HelpMessage="Exclude direct changes to files in pathSpec from being published.")]
        [Alias("ed")]
        [switch] $ExcludeDirectChanges
    )

    $modulesToPublish = @()
    if ($CommitRange -eq "") {
        $files = Get-ChildItem -Recurse -Path $PathSpec
        $modulesToPublish = @()
        foreach ($file in $files) {
            $modulesToPublish += [BicepModule]::new($file)
        }
    } else {
        $modulesToPublish = Get-BicepModuleChanged $PathSpec $CommitRange -ExcludeDirectChanges:$ExcludeDirectChanges
    }

    Write-InformationEx "Get-BicepModuleForPublish: Found $($modulesToPublish.Count) files to publish." -ForegroundColor Green
    $modulesToPublish | Format-Table | Out-String | Write-InformationEx

    return $modulesToPublish
}

Export-ModuleMember Get-BicepModuleForPublish
class BicepImport {
    [ValidateNotNullOrEmpty()][string]$Alias
    [ValidateNotNullOrEmpty()][string]$Name
    [string]$Version
    [string]$RegistryUrl
    [string]$LatestVersion
    [array]$FilePaths
}

function Get-BicepModuleImport([string] $pathSpec) {
    Write-InformationEx "Get-BicepModuleImport: $pathSpec" -ForegroundColor Green
    $moduleReferences = Get-ChildItem -recurse -Path $pathSpec | Select-String -pattern "\bmodule\b", "\bimport\b" | Select-Object

    $modules = @()
    for(($index = 0); $index -lt $moduleReferences.Count; $index++) {
        $moduleReference = $moduleReferences[$index]

        Format-Message "Reference $($moduleReference)" -Index $index | Write-DebugEx
        Format-Message "Line: '$($moduleReference.Line)'" -Index $index | Write-DebugEx

        $beginIndex = $moduleReference.Line.IndexOf("'")+1
        $endIndex = $moduleReference.Line.IndexOf("'", $beginIndex)
        Format-Message "Begin: $($beginIndex) - End: $($endIndex)" -Index $index | Write-DebugEx

        $module = $moduleReference.Line.SubString($beginIndex, $endIndex - $beginIndex)
        Format-Message "Module: '$($module)'" -Index $index | Write-DebugEx

        $alias = ''
        $name = ''
        $version = ''

        if ($module.Contains(':')) {
            $moduleParts = $module.Split(':')

            $alias = $moduleParts[0]
            $name = $moduleParts[1]
            $version = $moduleParts[2]
        } elseif ($module.Contains('@')) {
        } elseif ($module.Contains('.bicep')) {
            $fileDir = Split-Path -Path $moduleReference.Path -Parent
            Format-Message "FileDir: $($fileDir)" -Index $index | Write-DebugEx
            $moduleName = Resolve-Path "$($fileDir)/$($module)" -Relative
            Format-Message "ModuleName: $($moduleName)" -Index $index | Write-DebugEx

            $alias = '.'
            $name = $moduleName
            $version = ''
        }

        $existingModule = $modules | Where-Object { $_.Alias -eq $alias -And $_.Name -eq $name}
        if ($null -eq $existingModule) {
            $modules += [BicepImport]@{
                Alias = $alias
                Name = $name
                Version = $version
                FilePaths = @($moduleReference.Path)
            }
        } else  {
            if ($existingModule.FilePaths -notcontains $moduleReference.Path) {
                $existingModule.FilePaths += $moduleReference.Path
            }
        }
        Write-DebugEx "-------------- END REFERENCE $($index) --------------"
    }

    Write-InformationEx "Get-BicepModuleImport: Found $($modules.Count) imports." -ForegroundColor Green
    $modules | Select-Object Alias, Name, Version, FilePaths | Format-Table | Out-String | Write-InformationEx
    return $modules
}

Export-ModuleMember Get-BicepImport
function Publish-BicepModule {
    Param(
        [Parameter(Mandatory = $true, Position = 0, HelpMessage = "PathSpec to grep Bicep modules to publish.")]
        [ValidateNotNullOrWhiteSpace()]
        [string] $PathSpec,
        [Parameter(Mandatory = $true, Position = 1, HelpMessage = "Registry name of Azure container registry to which to publish.")]
        [ValidateNotNullOrWhiteSpace()]
        [string] $RegistryName,
        [Parameter(Mandatory = $true, Position = 2, HelpMessage = "Version to be tagged to published modules.")]
        [ValidateNotNullOrWhiteSpace()]
        [string] $Version,
        [Parameter(Mandatory = $false, HelpMessage = "Commit range to check for changes.")]
        [Alias("cr")]
        [string] $CommitRange,
        [Parameter(Mandatory = $false, HelpMessage = "Exclude direct changes to files in pathSpec from being published.")]
        [Alias("ed")]
        [switch] $ExcludeDirectChanges
    )

    $modulesToPublish = Get-BicepModuleForPublish $PathSpec $CommitRange -ExcludeDirectChanges:$ExcludeDirectChanges

    foreach ($module in $modulesToPublish) {
        Write-InformationEx "Publishing module $($module.Name) with version $($Version) to registry $($RegistryName)"
        az bicep publish --file $module.FilePath --target "br:$($RegistryName).azurecr.io/$($module.Name):$($Version)" --only-show-errors
    }
}

Export-ModuleMember Publish-BicepModule
class FileToUpdate {
    [string]$Path
    [array]$Modules
}

<#
.SYNOPSIS
 
Updates the versions of the imports & modules from custom repositories to the latest version available in the registry.
 
.DESCRIPTION
 
Extracts from all files mathcing the pathspec, imports & module declarations that are using the custom repository syntax alias:modulename:version.
Checks a newer version in the registry and updates the version in the files to the latest version available in the registry.
Current support is limited to Azure Container Registry (ACR).
 
.INPUTS
 
None. You cannot pipe objects to Add-Extension.
 
.OUTPUTS
 
None.
 
.EXAMPLE
 
PS> Update-BicepModuleVersion '*.bicep'
 
.LINK
 
https://github.com/lukaferlez/Simpleverse.Bicep/blob/main/README.md
 
#>

function Update-BicepModuleVersion {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact= 'High')]
    Param(
        [Parameter(Mandatory = $true, Position = 0, HelpMessage = "PathSpec to grep Bicep modules to update.")]
        [ValidateNotNullOrWhiteSpace()]
        [string] $PathSpec,
        [Parameter(Mandatory = $false, HelpMessage = "Path to bicepconfig.json with defined registries.")]
        [Alias("b")]
        [string] $BicepConfigPath = 'bicepconfig.json',
        [Parameter(Mandatory = $false, HelpMessage = "Force update without confirmation.")]
        [Alias("f")]
        [switch] $Force
    )

    $modules = Get-BicepModuleImport $PathSpec | Where-Object { $_.Alias -ne '.' }

    Write-InformationEx "Found $($modules.Count) modules." -ForegroundColor Green
    $modules | Select-Object Alias, Name, Version | Format-Table | Out-String | Write-InformationEx

    Write-InformationEx "Gathering latest versions from registry source."
    $bicepConfig = Get-Content $BicepConfigPath | ConvertFrom-Json -AsHashtable
    foreach ($module in $modules) {
        $aliasSplit = $module.Alias.Split("/")
        $module.registryUrl = $bicepConfig['moduleAliases'][$aliasSplit[0]][$aliasSplit[1]]['registry']
        Write-InformationEx "Checking $($module.Alias) from registry $($module.registryUrl) for $($module.Name)"
        $module.LatestVersion = az acr repository show-tags --name $module.RegistryUrl.Replace('.azurecr.io', '') --repository $module.Name --top 1 --orderby time_desc | ConvertFrom-Json
    }

    $modulesForUpdate = $modules | Where-Object { $_.Version -ne $_.LatestVersion }

    if ($modulesForUpdate.Count -eq 0) {
        Write-InformationEx "All modules are up to date." -ForegroundColor Green
        return
    }

    Write-InformationEx "Modules to update."
    $modules | Where-Object { $_.Version -ne $_.LatestVersion } | Select-Object Alias, Name, Version, LatestVersion | Format-Table | Out-String | Write-InformationEx

    $filesToUpdate = @()
    foreach ($module in $modules | Where-Object { $_.Version -ne $_.LatestVersion }) {
        foreach ($filePath in $module.FilePaths) {
            $existingFilePath = $filesToUpdate | Where-Object { $_.Path -eq $filePath }
            if ($null -eq $existingFilePath) {
                $filesToUpdate += [FileToUpdate]@{
                    Path = $filePath
                    Modules = @($module)
                }
            } else {
                $existingFilePath.Modules += $module
            }
        }
    }

    $filesToUpdate | Format-Table | Out-String | Write-InformationEx

    if ($Force -and -not $PSBoundParameters.ContainsKey('Confirm')) {
        $ConfirmPreference = 'None'
    }

    if ($PSCmdlet.ShouldProcess($filesToUpdate.Path, "Update")) {
        foreach ($fileToUpdate in $filesToUpdate) {
            $content = Get-Content $fileToUpdate.Path
            foreach ($module in $fileToUpdate.Modules) {
                $content = $content -replace "$($module.Alias):$($module.Name):$($module.Version)", "$($module.Alias):$($module.Name):$($module.LatestVersion)"
            }
            Set-Content -Path $fileToUpdate.Path -Value $content -Confirm:$false -WhatIf:$WhatIfPreference
        }

        Write-InformationEx "Updated $($filesToUpdate.Count) files." -ForegroundColor Green
    }
}

Export-ModuleMember Update-BicepModuleVersion