Simpleverse.Bicep.psm1

class BicepImport {
    [ValidateNotNullOrEmpty()][string]$Alias
    [ValidateNotNullOrEmpty()][string]$Name
    [string]$Version
    [string]$RegistryUrl
    [string]$LatestVersion
    [array]$FilePaths
}

function Get-BicepImports([string] $pathSpec) {
    Write-Host "Get-BicepImports: $pathSpec"
    $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]

        Write-DebugIndexed $index "Reference $($moduleReference)"
        Write-DebugIndexed $index "Line: '$($moduleReference.Line)'"

        $beginIndex = $moduleReference.Line.IndexOf("'")+1
        $endIndex = $moduleReference.Line.IndexOf("'", $beginIndex)        
        Write-DebugIndexed $index "Begin: $($beginIndex) - End: $($endIndex)"

        $module = $moduleReference.Line.SubString($beginIndex, $endIndex - $beginIndex)
        Write-DebugIndexed $index "Module: '$($module)'"

        $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
            Write-DebugIndexed $index "FileDir: $($fileDir)"
            $moduleName = Resolve-Path "$($fileDir)/$($module)" -Relative
            Write-DebugIndexed $index "ModuleName: $($moduleName)"

            $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-Debug "-------------- END REFERENCE $($index) --------------"
    }

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

Export-ModuleMember Get-BicepImports

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> Publish-BicepModules '*.bicep' 'd41eeb1c7c0a6a5e3f11efc175aa36b8eaae4af5..0ee2650f101237af9ad923ad2264d37b983d8bab'
 
.LINK
 
https://github.com/lukaferlez/Simpleverse.Bicep/blob/main/README.md
 
#>

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

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

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

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

    $bicepImports = Get-BicepImports $PathSpec

    function Get-ImpactedModules2 {
        Param(
            [BicepModule] $changedModule,
            $imports
        )
        Write-Debug "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-Debug "Get-ImpactedModules: $($changedModule.FilePath) - Discovered dependecies"

            foreach($filePath in $import.FilePaths) {
                Write-Debug "Get-ImpactedModules: $($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' | %{ $_.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-Host "Get-ImpactedModules: Found $($impactedModules.Count) impacted modules." -ForegroundColor Green
    $impactedModules | Select-Object Name, FilePath | Format-Table | Out-String | Write-Host

    return $impactedModules
}

Export-ModuleMember Get-BicepImpactedModules


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

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

    Write-Host "Get-ModulesToPublish: Found $($modulesToPublish.Count) files to publish." -ForegroundColor Green
    $modulesToPublish | Format-Table | Out-String | Write-Host
    
    return $modulesToPublish
}

Export-ModuleMember Get-BicepModulesToPublish

<#
.SYNOPSIS
 
Publishes changed Bicep modules to the Azure Container Registry (ACR).
 
.DESCRIPTION
 
Extracts changed files based on a pathspec and commit range.
Checks for usage in imports and module declarations on the same pathspace.
Publishes changed modules and dependants with a new version to the registry.
Current support is limited to Azure Container Registry (ACR).
 
.INPUTS
 
None. You cannot pipe objects to Add-Extension.
 
.OUTPUTS
 
None.
 
.EXAMPLE
 
PS> Publish-BicepModules '*.bicep' 'd41eeb1c7c0a6a5e3f11efc175aa36b8eaae4af5..0ee2650f101237af9ad923ad2264d37b983d8bab' someacr '2024.10.17.1'
 
.LINK
 
https://github.com/lukaferlez/Simpleverse.Bicep/blob/main/README.md
 
#>

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

    $modulesToPublish = Get-BicepModulesToPublish $PathSpec $CommitRange -IncludeNotChanged:$IncludeNotChanged -ExcludeDirectChanges:$ExcludeDirectChanges

    foreach ($module in $modulesToPublish) {
        Write-Host "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-BicepModules

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-BicepModulesVersion '*.bicep'
 
.LINK
 
https://github.com/lukaferlez/Simpleverse.Bicep/blob/main/README.md
 
#>

function Update-BicepModulesVersion {
    Param(
        [Parameter(Mandatory = $true, Position = 0, HelpMessage = "PathSpec to grep Bicep modules to update.")]
        [string] $PathSpec,
        [Parameter(Mandatory = $false, HelpMessage = "Path to bicepconfig.json with defined registries.")]
        [Alias("b")]
        [string] $BicepConfigPath = 'bicepconfig.json'
    )

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

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

    Write-Host "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-Host "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-Host "All modules are up to date." -ForegroundColor Green
        return
    }

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

    $update = Read-Host "Update? (Y/N)"
    if ($update -ne 'Y' -or $update -ne 'y') {
        return
    }

    $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-Host

    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
    }

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

Export-ModuleMember Update-BicepModulesVersion

function Write-DebugIndexed {
    Param(
        [int] $index,
        [string] $message
    )
    Write-Debug "Reference #$($index) - $($message)"
}