utilities/pipelines/sharedScripts/Set-ModuleReadMe.ps1 (1,412 lines of code) (raw):

#requires -version 7.3 #region helper functions <# .SYNOPSIS Test if an URL points to a valid online endpoint .DESCRIPTION Test if an URL points to a valid online endpoint .PARAMETER URL Mandatory. The URL to check .PARAMETER Retries Optional. The amount of times to retry .EXAMPLE Test-URl -URL 'www.github.com' Returns $true if the 'www.github.com' is valid, $false otherwise #> function Test-Url { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $URL, [Parameter(Mandatory = $false)] [int] $Retries = 3 ) $currentAttempt = 1 while ($currentAttempt -le $Retries) { try { $null = Invoke-WebRequest -Uri $URL return $true } catch { $currentAttempt++ Start-Sleep -Seconds 1 } } return $false } <# .SYNOPSIS Update the 'Resource Types' section of the given readme file .DESCRIPTION Update the 'Resource Types' section of the given readme file The section is added at the end if it does not exist .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the 'outputs' section. Defaults to '## Resource Types' .PARAMETER ResourceTypesToExclude Optional. The resource types to exclude from the list. By default excludes 'Microsoft.Resources/deployments' .EXAMPLE Set-ResourceTypesSection -TemplateFileContent @{ resource = @{}; ... } -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme file's 'Resource Types' section based on the given template file content #> function Set-ResourceTypesSection { [CmdletBinding(SupportsShouldProcess)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'ResourceTypesToExclude', Justification = 'Variable used inside Where-Object block.')] param ( [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory)] [object[]] $ReadMeFileContent, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Resource Types', [Parameter(Mandatory = $false)] [string[]] $ResourceTypesToExclude = @('Microsoft.Resources/deployments') ) # Process content $SectionContent = [System.Collections.ArrayList]@( '| Resource Type | API Version |', '| :-- | :-- |' ) $RelevantResourceTypeObjects = Get-NestedResourceList $TemplateFileContent | Where-Object { $_.type -notin $ResourceTypesToExclude -and $_ } | Select-Object 'Type', 'ApiVersion' -Unique | Sort-Object Type -Culture 'en-US' $ProgressPreference = 'SilentlyContinue' $VerbosePreference = 'SilentlyContinue' foreach ($resourceTypeObject in $RelevantResourceTypeObjects) { $ProviderNamespace, $ResourceType = $resourceTypeObject.Type -split '/', 2 # Validate if Reference URL is working $TemplatesBaseUrl = 'https://learn.microsoft.com/en-us/azure/templates' $ResourceReferenceUrl = '{0}/{1}/{2}/{3}' -f $TemplatesBaseUrl, $ProviderNamespace, $resourceTypeObject.ApiVersion, $ResourceType if (-not (Test-Url $ResourceReferenceUrl)) { # Validate if Reference URL is working using the latest documented API version (with no API version in the URL) $ResourceReferenceUrl = '{0}/{1}/{2}' -f $TemplatesBaseUrl, $ProviderNamespace, $ResourceType } if (-not (Test-Url $ResourceReferenceUrl)) { # Check if the resource is a child resource if ($ResourceType.Split('/').length -gt 1) { $ResourceReferenceUrl = '{0}/{1}/{2}' -f $TemplatesBaseUrl, $ProviderNamespace, $ResourceType.Split('/')[0] } else { # Use the default Templates URL (Last resort) $ResourceReferenceUrl = '{0}' -f $TemplatesBaseUrl } } $SectionContent += ('| `{0}` | [{1}]({2}) |' -f $resourceTypeObject.type, $resourceTypeObject.apiVersion, $ResourceReferenceUrl) } $ProgressPreference = 'Continue' $VerbosePreference = 'Continue' # Build result if ($PSCmdlet.ShouldProcess('Original file with new resource type content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $SectionContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Update the 'parameters' section of the given readme file .DESCRIPTION Update the 'parameters' section of the given readme file The section is added at the end if it does not exist .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER currentFolderPath Mandatory. The current folder path .PARAMETER SectionStartIdentifier Optional. The identifier of the 'outputs' section. Defaults to '## Parameters' .PARAMETER ColumnsInOrder Optional. The order of parameter categories to show in the readme parameters section. .EXAMPLE Set-ParametersSection -TemplateFileContent @{ resource = @{}; ... } -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme file's 'Parameters' section based on the given template file content #> function Set-ParametersSection { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory)] [object[]] $ReadMeFileContent, [Parameter(Mandatory)] [string] $currentFolderPath, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Parameters', [Parameter(Mandatory = $false)] [string[]] $ColumnsInOrder = @('Required', 'Conditional', 'Optional', 'Generated') ) # Invoking recursive function to resolve parameters $newSectionContent = Set-DefinitionSection -TemplateFileContent $TemplateFileContent -ColumnsInOrder $ColumnsInOrder # Build result if ($PSCmdlet.ShouldProcess('Original file with new parameters content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $newSectionContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Update parts of the 'parameters' section of the given readme file, if user defined types are used .DESCRIPTION Adds user defined types to the 'parameters' section of the given readme file .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER Properties Optional. Hashtable of the user defined properties .PARAMETER ParentName Optional. Name of the parameter, that has the user defined types .PARAMETER ParentIdentifierLink Optional. Link of the parameter, that has the user defined types .PARAMETER ColumnsInOrder Optional. The order of parameter categories to show in the readme parameters section. .EXAMPLE Set-DefinitionSection -TemplateFileContent @{ resource = @{}; ... } -ColumnsInOrder @('Required', 'Optional') Top-level invocation. Will start from the TemplateFile's parameters object and recursively crawl through all children. Tables will be ordered by 'Required' first and 'Optional' after. .EXAMPLE Set-DefinitionSection -TemplateFileContent @{ resource = @{}; ... } -Properties @{ @{ name = @{ type = 'string'; 'allowedValues' = @('A1','A2','A3','A4','A5','A6'); 'nullable' = $true; (...) } -ParentName 'diagnosticSettings' -ParentIdentifierLink '#parameter-diagnosticsettings' .NOTES The function is recursive and will also output grand, great grand children, ... . #> function Set-DefinitionSection { param ( [Parameter(Mandatory = $true)] [hashtable] $TemplateFileContent, [Parameter(Mandatory = $false)] [hashtable] $Properties, [Parameter(Mandatory = $false)] [string] $ParentName, [Parameter(Mandatory = $false)] [string] $ParentIdentifierLink, [Parameter(Mandatory = $false)] [string[]] $ColumnsInOrder = @('Required', 'Conditional', 'Optional', 'Generated') ) if (-not $Properties) { # Top-level invocation # Get all descriptions $descriptions = $TemplateFileContent.parameters.Values.metadata.description # Add name as property for later reference $TemplateFileContent.parameters.Keys | ForEach-Object { $TemplateFileContent.parameters[$_]['name'] = $_ } } else { $descriptions = $Properties.Values.metadata.description # Add name as property for later reference $Properties.Keys | ForEach-Object { $Properties[$_]['name'] = $_ } } # Get the module parameter categories $paramCategories = $descriptions | ForEach-Object { $_.Split('.')[0] } | Select-Object -Unique # Sort categories $sortedParamCategories = $ColumnsInOrder | Where-Object { $paramCategories -contains $_ } # Add all others that exist but are not specified in the columnsInOrder parameter $sortedParamCategories += $paramCategories | Where-Object { $ColumnsInOrder -notcontains $_ } $newSectionContent = [System.Collections.ArrayList]@() $tableSectionContent = [System.Collections.ArrayList]@() $listSectionContent = [System.Collections.ArrayList]@() foreach ($category in $sortedParamCategories) { # 1. Prepare # Filter to relevant items if (-not $Properties) { # Top-level invocation [array] $categoryParameters = $TemplateFileContent.parameters.Values | Where-Object { $_.metadata.description -like "$category. *" } | Sort-Object -Property 'Name' -Culture 'en-US' } else { $categoryParameters = $Properties.Values | Where-Object { $_.metadata.description -like "$category. *" } | Sort-Object -Property 'Name' -Culture 'en-US' } $tableSectionContent += @( ('**{0} parameters**' -f $category), '', '| Parameter | Type | Description |', '| :-- | :-- | :-- |' ) foreach ($parameter in $categoryParameters) { ###################### # Gather details # ###################### $paramIdentifier = (-not [String]::IsNullOrEmpty($ParentName)) ? '{0}.{1}' -f $ParentName, $parameter.name : $parameter.name $paramHeader = '### Parameter: `{0}`' -f $paramIdentifier $paramIdentifierLink = (-not [String]::IsNullOrEmpty($ParentIdentifierLink)) ? ('{0}{1}' -f $ParentIdentifierLink, $parameter.name).ToLower() : ('#{0}' -f $paramHeader.TrimStart('#').Trim().ToLower()) -replace '[:|`]' -replace ' ', '-' # definition type (if any) if ($parameter.Keys -contains '$ref') { $identifier = Split-Path $parameter.'$ref' -Leaf $definition = $TemplateFileContent.definitions[$identifier] $type = $definition['type'] $rawAllowedValues = $definition['allowedValues'] } else { $definition = $null $type = $parameter.type $rawAllowedValues = $parameter.allowedValues } $isRequired = (Get-IsParameterRequired -TemplateFileContent $TemplateFileContent -Parameter $parameter) ? 'Yes' : 'No' $description = $parameter.ContainsKey('metadata') ? $parameter['metadata']['description'].substring("$category. ".Length).Replace("`n- ", '<li>').Replace("`r`n", '<p>').Replace("`n", '<p>') : $null ##################### # Table content # ##################### # build table for definition properties $tableSectionContent += ('| [`{0}`]({1}) | {2} | {3} |' -f $parameter.name, $paramIdentifierLink, $type, $description) #################### # List content # #################### # Format default values # ===================== if ($parameter.defaultValue -is [array]) { if ($parameter.defaultValue.count -eq 0) { $defaultValue = '[]' } else { $bicepJSONDefaultParameterObject = @{ $parameter.name = ($parameter.defaultValue ?? @()) } # Wrapping on object to work with formatted Bicep script $bicepRawformattedDefault = ConvertTo-FormattedBicep -JSONParameters $bicepJSONDefaultParameterObject $leadingSpacesToTrim = ($bicepRawformattedDefault -match '^(\s+).+') ? $matches[1].Length : 0 $bicepCleanedFormattedDefault = $bicepRawformattedDefault -replace ('{0}: ' -f $parameter.name) # Unwrapping the object $defaultValue = $bicepCleanedFormattedDefault -split '\n' | ForEach-Object { $_ -replace "^\s{$leadingSpacesToTrim}" } # Removing excess leading spaces } } elseif ($parameter.defaultValue -is [hashtable]) { if ($parameter.defaultValue.count -eq 0) { $defaultValue = '{}' } else { $bicepDefaultValue = ConvertTo-FormattedBicep -JSONParameters $parameter.defaultValue $defaultValue = "{`n$bicepDefaultValue`n}" } } elseif ($parameter.defaultValue -is [string] -and ($parameter.defaultValue -notmatch '\[\w+\(.*\).*\]')) { $defaultValue = '''' + $parameter.defaultValue + '''' } else { $defaultValue = $parameter.defaultValue } if (-not [String]::IsNullOrEmpty($defaultValue)) { if (($defaultValue -split '\n').count -eq 1) { $formattedDefaultValue = '- Default: `{0}`' -f $defaultValue } else { $formattedDefaultValue = @( '- Default:', ' ```Bicep', ($defaultValue -split '\n' | ForEach-Object { " $_" } | Out-String).TrimEnd(), ' ```' ) } } else { $formattedDefaultValue = $null } # Format allowed values # ===================== if ($rawAllowedValues -is [array]) { $bicepJSONAllowedParameterObject = @{ $parameter.name = ($rawAllowedValues ?? @()) } # Wrapping on object to work with formatted Bicep script $bicepRawformattedAllowed = ConvertTo-FormattedBicep -JSONParameters $bicepJSONAllowedParameterObject $leadingSpacesToTrim = ($bicepRawformattedAllowed -match '^(\s+).+') ? $matches[1].Length : 0 $bicepCleanedFormattedAllowed = $bicepRawformattedAllowed -replace ('{0}: ' -f $parameter.name) # Unwrapping the object $allowedValues = $bicepCleanedFormattedAllowed -split '\n' | ForEach-Object { $_ -replace "^\s{$leadingSpacesToTrim}" } # Removing excess leading spaces } elseif ($rawAllowedValues -is [hashtable]) { $bicepAllowedValues = ConvertTo-FormattedBicep -JSONParameters $rawAllowedValues $allowedValues = "{`n$bicepAllowedValues`n}" } else { $allowedValues = $rawAllowedValues } if (-not [String]::IsNullOrEmpty($allowedValues)) { if (($allowedValues -split '\n').count -eq 1) { $formattedAllowedValues = '- Default: `{0}`' -f $allowedValues } else { $formattedAllowedValues = @( '- Allowed:', ' ```Bicep', ($allowedValues -split '\n' | Where-Object { -not [String]::IsNullOrEmpty($_) } | ForEach-Object { " $_" } | Out-String).TrimEnd(), ' ```' ) } } else { $formattedAllowedValues = $null } # Build list item # =============== $listSectionContent += @( $paramHeader, ($parameter.ContainsKey('metadata') ? '' : $null), $description ($parameter.ContainsKey('metadata') ? '' : $null), ('- Required: {0}' -f $isRequired), ('- Type: {0}' -f $type), ((-not [String]::IsNullOrEmpty($formattedDefaultValue)) ? $formattedDefaultValue : $null), ((-not [String]::IsNullOrEmpty($formattedAllowedValues)) ? $formattedAllowedValues : $null) '' ) | Where-Object { $null -ne $_ } #recursive call for children if ($definition) { if ($definition.ContainsKey('items') -and $definition['items'].ContainsKey('properties')) { $childProperties = $definition['items']['properties'] $sectionContent = Set-DefinitionSection -TemplateFileContent $TemplateFileContent -Properties $childProperties -ParentName $paramIdentifier -ParentIdentifierLink $paramIdentifierLink -ColumnsInOrder $ColumnsInOrder $listSectionContent += $sectionContent } elseif ($definition.type -eq 'object' -and $definition['properties']) { $childProperties = $definition['properties'] $sectionContent = Set-DefinitionSection -TemplateFileContent $TemplateFileContent -Properties $childProperties -ParentName $paramIdentifier -ParentIdentifierLink $paramIdentifierLink -ColumnsInOrder $ColumnsInOrder $listSectionContent += $sectionContent } } } $tableSectionContent += '' } $newSectionContent += $tableSectionContent $newSectionContent += $listSectionContent return $newSectionContent } <# .SYNOPSIS Update the 'outputs' section of the given readme file .DESCRIPTION Update the 'outputs' section of the given readme file The section is added at the end if it does not exist .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the 'outputs' section. Defaults to '## Outputs' .EXAMPLE Set-OutputsSection -TemplateFileContent @{ resource = @{}; ... } -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme file's 'Outputs' section based on the given template file content #> function Set-OutputsSection { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory)] [object[]] $ReadMeFileContent, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Outputs' ) # Process content if ($TemplateFileContent.outputs.Values.metadata) { # Template has output descriptions $SectionContent = [System.Collections.ArrayList]@( '| Output | Type | Description |', '| :-- | :-- | :-- |' ) foreach ($outputName in ($templateFileContent.outputs.Keys | Sort-Object -Culture 'en-US')) { $output = $TemplateFileContent.outputs[$outputName] $description = $output.metadata.description.Replace("`r`n", '<p>').Replace("`n", '<p>') $SectionContent += ("| ``{0}`` | {1} | {2} |" -f $outputName, $output.type, $description) } } else { $SectionContent = [System.Collections.ArrayList]@( '| Output | Type |', '| :-- | :-- |' ) foreach ($outputName in ($templateFileContent.outputs.Keys | Sort-Object -Culture 'en-US')) { $output = $TemplateFileContent.outputs[$outputName] $SectionContent += ("| ``{0}`` | {1} |" -f $outputName, $output.type) } } # Build result if ($PSCmdlet.ShouldProcess('Original file with new output content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $SectionContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Add module references (cross-references) to the module's readme .DESCRIPTION Add module references (cross-references) to the module's readme. This includes both local (i.e., file path), as well as remote references (e.g., ACR) .PARAMETER ModuleRoot Mandatory. The file path to the module's root .PARAMETER FullModuleIdentifier Mandatory. The full identifier of the module (i.e., ProviderNamespace + ResourceType) .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the 'outputs' section. Defaults to '## Cross-referenced modules' .PARAMETER CrossReferencedModuleList Required. The Cross Module References to consider when refreshing the readme. .EXAMPLE Set-CrossReferencesSection -ModuleRoot 'C:/key-vault/vault' -FullModuleIdentifier 'key-vault/vault' -TemplateFileContent @{ resource = @{}; ... } -ReadMeFileContent @('# Title', '', '## Section 1', ...) -CrossReferencedModuleList @{} Update the given readme file's 'Cross-referenced modules' section based on the given template file content #> function Set-CrossReferencesSection { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [string] $ModuleRoot, [Parameter(Mandatory = $true)] [string] $FullModuleIdentifier, [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory)] [object[]] $ReadMeFileContent, [Parameter(Mandatory)] [hashtable] $CrossReferencedModuleList, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Cross-referenced modules' ) # Process content $SectionContent = [System.Collections.ArrayList]@( 'This section gives you an overview of all local-referenced module files (i.e., other CARML modules that are referenced in this module) and all remote-referenced files (i.e., Bicep modules that are referenced from a Bicep Registry or Template Specs).', '', '| Reference | Type |', '| :-- | :-- |' ) $dependencies = $CrossReferencedModuleList[$FullModuleIdentifier] if ($dependencies.Keys -contains 'localPathReferences' -and $dependencies['localPathReferences']) { foreach ($reference in ($dependencies['localPathReferences'] | Sort-Object)) { $SectionContent += ("| ``{0}`` | {1} |" -f $reference, 'Local reference') } } if ($dependencies.Keys -contains 'remoteReferences' -and $dependencies['remoteReferences']) { foreach ($reference in ($dependencies['remoteReferences'] | Sort-Object)) { $SectionContent += ("| ``{0}`` | {1} |" -f $reference, 'Remote reference') } } if ($SectionContent.Count -eq 4) { # No content was added, adding placeholder $SectionContent = @('_None_') } # Build result if ($PSCmdlet.ShouldProcess('Original file with new output content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $SectionContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Add comments to indicate required & non-required parameters to the given Bicep example .DESCRIPTION Add comments to indicate required & non-required parameters to the given Bicep example. 'Required' is only added if the example has at least one required parameter 'Non-Required' is only added if the example has at least one required parameter and at least one non-required parameter .PARAMETER BicepParams Mandatory. The Bicep parameter block to add the comments to (i.e., should contain everything in between the brackets of a 'params: {...} block) .PARAMETER AllParametersList Mandatory. A list of all top-level (i.e. non-nested) parameter names .PARAMETER RequiredParametersList Mandatory. A list of all required top-level (i.e. non-nested) parameter names .EXAMPLE Add-BicepParameterTypeComment -AllParametersList @('name', 'lock') -RequiredParametersList @('name') -BicepParams "name: 'carml'\nlock: 'CanNotDelete'" Add type comments to given bicep params string, using one required parameter 'name'. Would return: ' // Required parameters name: 'carml' // Non-required parameters lock: { kind: 'CanNotDelete' name: 'myCustomLockName' } ' #> function Add-BicepParameterTypeComment { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $BicepParams, [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [string[]] $AllParametersList = @(), [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [string[]] $RequiredParametersList = @() ) if ($RequiredParametersList.Count -ge 1 -and $AllParametersList.Count -ge 2) { $BicepParamsArray = $BicepParams -split '\n' # [1/4] Check where the 'last' required parameter is located in the example (and what its indent is) $parameterToSplitAt = $RequiredParametersList[-1] $requiredParameterIndent = ([regex]::Match($BicepParamsArray[0], '^(\s+).*')).Captures.Groups[1].Value.Length # [2/4] Add a comment where the required parameters start $BicepParamsArray = @('{0}// Required parameters' -f (' ' * $requiredParameterIndent)) + $BicepParamsArray[(0 .. ($BicepParamsArray.Count))] # [3/4] Find the location if the last required parameter $requiredParameterStartIndex = ($BicepParamsArray | Select-String ('^[\s]{0}{1}:.+' -f "{$requiredParameterIndent}", $parameterToSplitAt) | ForEach-Object { $_.LineNumber - 1 })[0] # [4/4] If we have more than only required parameters, let's add a corresponding comment if ($AllParametersList.Count -gt $RequiredParametersList.Count) { $nextLineIndent = ([regex]::Match($BicepParamsArray[$requiredParameterStartIndex + 1], '^(\s+).*')).Captures.Groups[1].Value.Length if ($nextLineIndent -gt $requiredParameterIndent) { # Case Param is object/array: Search in rest of array for the next closing bracket with the same indent - and then add the search index (1) & initial index (1) count back in $requiredParameterEndIndex = ($BicepParamsArray[($requiredParameterStartIndex + 1)..($BicepParamsArray.Count)] | Select-String "^[\s]{$requiredParameterIndent}\S+" | ForEach-Object { $_.LineNumber - 1 })[0] + 1 + $requiredParameterStartIndex } else { # Case Param is single line bool/string/int: Add an index (1) for the 'required' comment $requiredParameterEndIndex = $requiredParameterStartIndex } # Add a comment where the non-required parameters start $BicepParamsArray = $BicepParamsArray[0..$requiredParameterEndIndex] + ('{0}// Non-required parameters' -f (' ' * $requiredParameterIndent)) + $BicepParamsArray[(($requiredParameterEndIndex + 1) .. ($BicepParamsArray.Count))] } return ($BicepParamsArray | Out-String).TrimEnd() } return $BicepParams } <# .SYNOPSIS Sort the given JSON paramters into required & non-required parameters, each sorted alphabetically .DESCRIPTION Sort the given JSON paramters into required & non-required parameters, each sorted alphabetically .PARAMETER ParametersJSON Mandatory. The JSON parameters block to process (ideally already without 'value' property) .PARAMETER RequiredParametersList Mandatory. A list of all required top-level (i.e. non-nested) parameter names .EXAMPLE Get-OrderedParametersJSON -RequiredParametersList @('name') -ParametersJSON '{ "lock": "CanNotDelete","name": "carml" }' Order the given JSON object alphabetically. Would result into: @{ name: 'carml' lock: { kind: 'CanNotDelete' name: 'myCustomLockName' } } #> function Get-OrderedParametersJSON { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ParametersJSON, [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [string[]] $RequiredParametersList = @() ) # [1/3] Get all parameters from the parameter object and order them recursively $orderedContentInJSONFormat = ConvertTo-OrderedHashtable -JSONInputObject $parametersJSON # [2/3] Sort 'required' parameters to the front $orderedJSONParameters = [ordered]@{} $orderedTopLevelParameterNames = $orderedContentInJSONFormat.psbase.Keys # We must use PS-Base to handle conflicts of HashTable properties & keys (e.g. for a key 'keys'). # [2.1] Add required parameters first $orderedTopLevelParameterNames | Where-Object { $_ -in $RequiredParametersList } | ForEach-Object { $orderedJSONParameters[$_] = $orderedContentInJSONFormat[$_] } # [2.2] Add rest after $orderedTopLevelParameterNames | Where-Object { $_ -notin $RequiredParametersList } | ForEach-Object { $orderedJSONParameters[$_] = $orderedContentInJSONFormat[$_] } # [3/3] Handle empty dictionaries (in case the parmaeter file was empty) if ($orderedJSONParameters.count -eq 0) { $orderedJSONParameters = '' } return $orderedJSONParameters } <# .SYNOPSIS Sort the given JSON parameters into a new JSON parameter object, all parameter sorted into required & non-required parameters, each sorted alphabetically .DESCRIPTION Sort the given JSON parameters into a new JSON parameter object, all parameter sorted into required & non-required parameters, each sorted alphabetically. The location where required & non-required parameters start is highlighted with by a corresponding comment .PARAMETER ParametersJSON Mandatory. The parameter JSON object to process .PARAMETER RequiredParametersList Mandatory. A list of all required top-level (i.e. non-nested) parameter names .EXAMPLE Build-OrderedJSONObject -RequiredParametersList @('name') -ParametersJSON '{ "lock": { "value": "CanNotDelete" }, "name": { "value": "carml" } }' Build a formatted Parameter-JSON object with one required parameter. Would result into: '{ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", "contentVersion": "1.0.0.0", "parameters": { // Required parameters "name": { "value": "carml" }, // Non-required parameters "lock": { "value": "CanNotDelete" } } }' #> function Build-OrderedJSONObject { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ParametersJSON, [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [string[]] $RequiredParametersList = @() ) # [1/9] Sort parameter alphabetically $orderedJSONParameters = Get-OrderedParametersJSON -ParametersJSON $ParametersJSON -RequiredParametersList $RequiredParametersList # [2/9] Build the ordered parameter file syntax back up $jsonExample = ([ordered]@{ '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' contentVersion = '1.0.0.0' parameters = (-not [String]::IsNullOrEmpty($orderedJSONParameters)) ? $orderedJSONParameters : @{} } | ConvertTo-Json -Depth 99) # [3/8] If we have at least one required and one other parameter we want to add a comment if ($RequiredParametersList.Count -ge 1 -and $OrderedJSONParameters.Keys.Count -ge 2) { $jsonExampleArray = $jsonExample -split '\n' # [4/8] Check where the 'last' required parameter is located in the example (and what its indent is) $parameterToSplitAt = $RequiredParametersList[-1] $parameterStartIndex = ($jsonExampleArray | Select-String '.*"parameters": \{.*' | ForEach-Object { $_.LineNumber - 1 })[0] $requiredParameterIndent = ([regex]::Match($jsonExampleArray[($parameterStartIndex + 1)], '^(\s+).*')).Captures.Groups[1].Value.Length # [5/8] Add a comment where the required parameters start $jsonExampleArray = $jsonExampleArray[0..$parameterStartIndex] + ('{0}// Required parameters' -f (' ' * $requiredParameterIndent)) + $jsonExampleArray[(($parameterStartIndex + 1) .. ($jsonExampleArray.Count))] # [6/8] Find the location if the last required parameter $requiredParameterStartIndex = ($jsonExampleArray | Select-String "^[\s]{$requiredParameterIndent}`"$parameterToSplitAt`": \{.*" | ForEach-Object { $_.LineNumber - 1 })[0] # [7/8] If we have more than only required parameters, let's add a corresponding comment if ($orderedJSONParameters.Keys.Count -gt $RequiredParametersList.Count ) { # Search in rest of array for the next closing bracket with the same indent - and then add the search index (1) & initial index (1) count back in $requiredParameterEndIndex = ($jsonExampleArray[($requiredParameterStartIndex + 1)..($jsonExampleArray.Count)] | Select-String "^[\s]{$requiredParameterIndent}\}" | ForEach-Object { $_.LineNumber - 1 })[0] + 1 + $requiredParameterStartIndex # Add a comment where the non-required parameters start $jsonExampleArray = $jsonExampleArray[0..$requiredParameterEndIndex] + ('{0}// Non-required parameters' -f (' ' * $requiredParameterIndent)) + $jsonExampleArray[(($requiredParameterEndIndex + 1) .. ($jsonExampleArray.Count))] } # [8/8] Convert the processed array back into a string return $jsonExampleArray | Out-String } return $jsonExample } <# .SYNOPSIS Convert the given Bicep parameter block to JSON parameter block .DESCRIPTION Convert the given Bicep parameter block to JSON parameter block .PARAMETER BicepParamBlock Mandatory. The Bicep parameter block to process .PARAMETER CurrentFilePath Mandatory. The Path of the file containing the param block .EXAMPLE ConvertTo-FormattedJSONParameterObject -BicepParamBlock "name: 'carml'\nlock: 'CanNotDelete'" -CurrentFilePath 'c:/main.test.bicep' Convert the Bicep string "name: 'carml'\nlock: 'CanNotDelete'" into a parameter JSON object. Would result into: @{ lock = @{ value = 'carml' } lock = @{ value = 'CanNotDelete' } } #> function ConvertTo-FormattedJSONParameterObject { [CmdletBinding()] param ( [Parameter()] [string] $BicepParamBlock, [Parameter()] [string] $CurrentFilePath ) if ([String]::IsNullOrEmpty($BicepParamBlock)) { # Case: No mandatory parameters return @{} } # [1/4] Detect top level params for later processing $bicepParamBlockArray = $BicepParamBlock -split '\n' $topLevelParamIndent = ([regex]::Match($bicepParamBlockArray[0], '^(\s+).*')).Captures.Groups[1].Value.Length $topLevelParams = $bicepParamBlockArray | Where-Object { $_ -match "^\s{$topLevelParamIndent}[0-9a-zA-Z]+:.*" } | ForEach-Object { ($_ -split ':')[0].Trim() } # [2/4] Add JSON-specific syntax to the Bicep param block to enable us to treat is as such # [2.1] Syntax: Outer brackets $paramInJsonFormat = @( '{', $BicepParamBlock '}' ) | Out-String # [2.2] Syntax: All double quotes must be escaped & single-quotes are double-quotes $paramInJsonFormat = $paramInJsonFormat -replace '"', '\"' $paramInJsonFormat = $paramInJsonFormat -replace "'", '"' # [2.3] Split the object to format line-by-line (& also remove any empty lines) $paramInJSONFormatArray = $paramInJsonFormat -split '\n' | Where-Object { -not [String]::IsNullOrEmpty($_.Trim()) } for ($index = 0; $index -lt $paramInJSONFormatArray.Count; $index++) { $line = $paramInJSONFormatArray[$index] # [2.4] Syntax: # - Everything left of a leftest ':' should be wrapped in quotes (as a parameter name is always a string) # - However, we don't want to accidently catch something like "CriticalAddonsOnly=true:NoSchedule" [regex]$pattern = '^\s*\"{0}([0-9a-zA-Z_]+):' $line = $pattern.replace($line, '"$1":', 1) # [2.5] Syntax: Replace Bicep resource ID references $mayHaveValue = $line -match '\s*.+:\s+' if ($mayHaveValue) { $lineValue = ($line -split '\s*.+:\s+')[1].Trim() # i.e. optional spaces, followed by a name ("xzy"), followed by ':', folowed by at least a space # Individual checks $isLineWithEmptyObjectValue = $line -match '^.+:\s*{\s*}\s*$' # e.g. test: {} $isLineWithObjectPropertyReferenceValue = $lineValue -like '*.*' # e.g. resourceGroupResources.outputs.virtualWWANResourceId` $isLineWithReferenceInLineKey = ($line -split ':')[0].Trim() -like '*.*' $isLineWithStringValue = $lineValue -match '".+"' # e.g. "value" $isLineWithFunction = $lineValue -match "^['|`"]{1}.*\$\{.+['|`"]{1}$|^['|`"]{0}[a-zA-Z\(]+\(.+" # e.g. (split(resourceGroupResources.outputs.recoveryServicesVaultResourceId, "/"))[4] or '${last(...)}' or last() or "test${environment()}" $isLineWithPlainValue = $lineValue -match '^\w+$' # e.g. adminPassword: password $isLineWithPrimitiveValue = $lineValue -match '^\s*true|false|[0-9]+$' # e.g. isSecure: true # Combined checks # In case of an output reference like '"virtualWanId": resourceGroupResources.outputs.virtualWWANResourceId' we'll only show "<virtualWanId>" (but NOT e.g. 'reference': {}) $isLineWithObjectPropertyReference = -not $isLineWithEmptyObjectValue -and -not $isLineWithStringValue -and $isLineWithObjectPropertyReferenceValue # In case of a parameter/variable reference like 'adminPassword: password' we'll only show "<adminPassword>" (but NOT e.g. enableMe: true) $isLineWithParameterOrVariableReferenceValue = $isLineWithPlainValue -and -not $isLineWithPrimitiveValue # In case of any contained line like ''${resourceGroupResources.outputs.managedIdentityResourceId}': {}' we'll only show "managedIdentityResourceId: {}" $isLineWithObjectReferenceKeyAndEmptyObjectValue = $isLineWithEmptyObjectValue -and $isLineWithReferenceInLineKey # In case of any contained function like '"backupVaultResourceGroup": (split(resourceGroupResources.outputs.recoveryServicesVaultResourceId, "/"))[4]' we'll only show "<backupVaultResourceGroup>" if ($isLineWithObjectPropertyReference -or $isLineWithFunction -or $isLineWithParameterOrVariableReferenceValue) { $line = '{0}: "<{1}>"' -f ($line -split ':')[0], ([regex]::Match(($line -split ':')[0], '"(.+)"')).Captures.Groups[1].Value } elseif ($isLineWithObjectReferenceKeyAndEmptyObjectValue) { $line = '"<{0}>": {1}' -f (($line -split ':')[0] -split '\.')[-1].TrimEnd('}"'), $lineValue } } else { if ($line -notlike '*"*"*' -and $line -like '*.*') { # In case of a array value like '[ \n -> resourceGroupResources.outputs.managedIdentityPrincipalId <- \n ]' we'll only show "<managedIdentityPrincipalId>"" $line = '"<{0}>"' -f $line.Split('.')[-1].Trim() } elseif ($line -match '^\s*[a-zA-Z]+\s*$') { # If there is simply only a value such as a variable reference, we'll wrap it as a string to replace. For example a reference of a variable `addressPrefix` will be replaced with `"<addressPrefix>"` $line = '"<{0}>"' -f $line.Trim() } } $paramInJSONFormatArray[$index] = $line } # [2.6] Syntax: Add comma everywhere unless: # - the current line has an opening 'object: {' or 'array: [' character # - the line after the current line has a closing 'object: {' or 'array: [' character # - it's the last closing bracket for ($index = 0; $index -lt $paramInJSONFormatArray.Count; $index++) { if (($paramInJSONFormatArray[$index] -match '[\{|\[]\s*$') -or (($index -lt $paramInJSONFormatArray.Count - 1) -and $paramInJSONFormatArray[$index + 1] -match '^\s*[\]|\}]\s*$') -or ($index -eq $paramInJSONFormatArray.Count - 1)) { continue } $paramInJSONFormatArray[$index] = '{0},' -f $paramInJSONFormatArray[$index].Trim() } # [2.7] Format the final JSON string to an object to enable processing try { $paramInJsonFormatObject = $paramInJSONFormatArray | Out-String | ConvertFrom-Json -AsHashtable -Depth 99 -ErrorAction 'Stop' } catch { throw ('Failed to process file [{0}]. Please check if it properly formatted. Original error message: [{1}]' -f $CurrentFilePath, $_.Exception.Message) } # [3/4] Inject top-level 'value`' properties $paramInJsonFormatObjectWithValue = @{} foreach ($paramKey in $topLevelParams) { $paramInJsonFormatObjectWithValue[$paramKey] = @{ value = $paramInJsonFormatObject[$paramKey] } } # [4/4] Return result return $paramInJsonFormatObjectWithValue } <# .SYNOPSIS Convert the given parameter JSON object into a formatted Bicep object (i.e., sorted & with required/non-required comments) .DESCRIPTION Convert the given parameter JSON object into a formatted Bicep object (i.e., sorted & with required/non-required comments) .PARAMETER JSONParameters Mandatory. The parameter JSON object to process. .PARAMETER RequiredParametersList Mandatory. A list of all required top-level (i.e. non-nested) parameter names .EXAMPLE ConvertTo-FormattedBicep -RequiredParametersList @('name') -JSONParameters @{ lock = @{ value = 'carml' }; lock = @{ value = 'CanNotDelete' } } Convert the given JSONParameters object with one required parameter to a formatted Bicep object. Would result into: ' // Required parameters name: 'carml' // Non-required parameters lock: { kind: 'CanNotDelete' name: 'myCustomLockName' } ' #> function ConvertTo-FormattedBicep { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [hashtable] $JSONParameters, [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [string[]] $RequiredParametersList = @() ) # [0/5] Remove 'value' parameter property, if any (e.g. when dealing with a classic parameter file) $JSONParametersWithoutValue = @{} foreach ($parameterName in $JSONParameters.psbase.Keys) { $keysOnLevel = $JSONParameters[$parameterName].Keys if ($keysOnLevel.count -eq 1 -and $keysOnLevel -eq 'value') { $JSONParametersWithoutValue[$parameterName] = $JSONParameters[$parameterName].value } else { $JSONParametersWithoutValue[$parameterName] = $JSONParameters[$parameterName] } } # [1/5] Order parameters recursively if ($JSONParametersWithoutValue.psbase.Keys.Count -gt 0) { $orderedJSONParameters = Get-OrderedParametersJSON -ParametersJSON ($JSONParametersWithoutValue | ConvertTo-Json -Depth 99) -RequiredParametersList $RequiredParametersList } else { $orderedJSONParameters = @{} } # [2/5] Remove any JSON specific formatting $templateParameterObject = $orderedJSONParameters | ConvertTo-Json -Depth 99 if ($templateParameterObject -ne '{}') { $bicepParamsArray = $templateParameterObject -split '\r?\n' | ForEach-Object { $line = $_ $line = $line -replace "'", "\'" # Update any [ "field": "[[concat('tags[', parameters('tagName'), ']')]"] to [ "field": "[[concat(\'tags[\', parameters(\'tagName\'), \']\')]"] $line = $line -replace '"', "'" # Update any [xyz: "xyz"] to [xyz: 'xyz'] $line = $line -replace ',$', '' # Update any [xyz: abc,xyz,] to [xyz: abc,xyz] $line = $line -replace "'(\w+)':", '$1:' # Update any ['xyz': xyz] to [xyz: xyz] $line = $line -replace "'(.+.getSecret\('.+'\))'", '$1' # Update any [xyz: 'xyz.GetSecret()'] to [xyz: xyz.GetSecret()] $line } $bicepParamsArray = $bicepParamsArray[1..($bicepParamsArray.count - 2)] # [3/5] Format 'getSecret' references $bicepParamsArray = $bicepParamsArray | ForEach-Object { if ($_ -match ".+: '(\w+)\.getSecret\(\\'([0-9a-zA-Z-<>]+)\\'\)'") { # e.g. change [pfxCertificate: 'kv1.getSecret(\'<certSecretName>\')'] to [pfxCertificate: kv1.getSecret('<certSecretName>')] "{0}: {1}.getSecret('{2}')" -f ($_ -split ':')[0], $matches[1], $matches[2] } else { $_ } } } else { $bicepParamsArray = @() } # [4/5] Format params with indent $bicepParams = ($bicepParamsArray | ForEach-Object { " $_" } | Out-String).TrimEnd() # [5/5] Add comment where required & optional parameters start $splitInputObject = @{ BicepParams = $bicepParams RequiredParametersList = $RequiredParametersList AllParametersList = $JSONParameters.psBase.Keys } $commentedBicepParams = Add-BicepParameterTypeComment @splitInputObject return $commentedBicepParams } <# .SYNOPSIS Generate 'Usage examples' for the ReadMe out of the parameter files currently used to test the template .DESCRIPTION Generate 'Usage examples' for the ReadMe out of the parameter files currently used to test the template .PARAMETER ModuleRoot Mandatory. The file path to the module's root .PARAMETER FullModuleIdentifier Mandatory. The full identifier of the module (i.e., ProviderNamespace + ResourceType) .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the 'outputs' section. Defaults to '## Usage examples' .PARAMETER addJson Optional. A switch to control whether or not to add a ARM-JSON-Parameter file example. Defaults to true. .PARAMETER addBicep Optional. A switch to control whether or not to add a Bicep usage example. Defaults to true. .EXAMPLE Set-UsageExamplesSection -ModuleRoot 'C:/key-vault/vault' -FullModuleIdentifier 'key-vault/vault' -TemplateFileContent @{ resource = @{}; ... } -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme file's 'Usage Examples' section based on the given template file content #> function Set-UsageExamplesSection { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [string] $ModuleRoot, [Parameter(Mandatory = $true)] [string] $FullModuleIdentifier, [Parameter(Mandatory)] [hashtable] $TemplateFileContent, [Parameter(Mandatory = $true)] [object[]] $ReadMeFileContent, [Parameter(Mandatory = $false)] [bool] $addJson = $true, [Parameter(Mandatory = $false)] [bool] $addBicep = $true, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Usage examples' ) $brLink = Get-PrivateRegistryRepositoryName -TemplateFilePath $TemplateFilePath # Process content $SectionContent = [System.Collections.ArrayList]@( "The following section provides usage examples for the module, which were used to validate and deploy the module successfully. For a full reference, please review the module's test folder in its repository.", '', '>**Note**: Each example lists all the required parameters first, followed by the rest - each in alphabetical order.', '', ('>**Note**: To reference the module, please use the following syntax `br:{0}:1.0.0`.' -f $brLink), '' ) ##################### ## Init values ## ##################### $specialConversionHash = @{ 'public-ip-addresses' = 'publicIPAddresses' 'public-ip-prefixes' = 'publicIPPrefixes' } # Get moduleName as $fullModuleIdentifier leaf $moduleName = $fullModuleIdentifier.Split('/')[1] if ($specialConversionHash.ContainsKey($moduleName)) { # Convert moduleName using specialConversionHash $moduleNameCamelCase = $specialConversionHash[$moduleName] } else { # Convert moduleName from kebab-case to camelCase $First, $Rest = $moduleName -Split '-', 2 $moduleNameCamelCase = $First.Tolower() + (Get-Culture).TextInfo.ToTitleCase($Rest) -Replace '-' } $testFilePaths = (Get-ChildItem -Path $ModuleRoot -Recurse -Filter 'main.test.bicep').FullName | Sort-Object $RequiredParametersList = $TemplateFileContent.parameters.Keys | Where-Object { Get-IsParameterRequired -TemplateFileContent $TemplateFileContent -Parameter $TemplateFileContent.parameters[$_] } | Sort-Object ############################ ## Process test files ## ############################ # Prepare data (using thread-safe multithreading) to consume later $buildTestFileMap = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new() $testFilePaths | ForEach-Object -Parallel { $folderName = Split-Path (Split-Path -Path $_) -Leaf $buildTemplate = (bicep build $_ --stdout 2>$null) | ConvertFrom-Json -AsHashtable $dict = $using:buildTestFileMap $null = $dict.TryAdd($folderName, $buildTemplate) } $pathIndex = 1 $usageExampleSectionHeaders = @() $testFilesContent = @() foreach ($testFilePath in $testFilePaths) { # Read content $rawContentArray = Get-Content -Path $testFilePath $folderName = Split-Path (Split-Path -Path $testFilePath) -Leaf $compiledTestFileContent = $buildTestFileMap[$folderName] $rawContent = Get-Content -Path $testFilePath -Encoding 'utf8' | Out-String # Format example header if ($compiledTestFileContent.metadata.Keys -contains 'name') { $exampleTitle = $compiledTestFileContent.metadata.name } else { if ((Split-Path (Split-Path $testFilePath -Parent) -Leaf) -ne '.test') { $exampleTitle = Split-Path (Split-Path $testFilePath -Parent) -Leaf } else { $exampleTitle = ((Split-Path $testFilePath -LeafBase) -replace '\.', ' ') -replace ' parameters', '' } $textInfo = (Get-Culture -Name 'en-US').TextInfo $exampleTitle = $textInfo.ToTitleCase($exampleTitle) } $fullTestFileTitle = '### Example {0}: _{1}_' -f $pathIndex, $exampleTitle $testFilesContent += @( $fullTestFileTitle ) $usageExampleSectionHeaders += @{ title = $exampleTitle header = $fullTestFileTitle } # If a description is added in the template's metadata, we can add it too if ($compiledTestFileContent.metadata.Keys -contains 'description') { $testFilesContent += @( '', $compiledTestFileContent.metadata.description, '' ) } # ------------------------- # # Prepare Bicep to JSON # # ------------------------- # # [1/6] Search for the relevant parameter start & end index $bicepTestStartIndex = ($rawContentArray | Select-String ("^module testDeployment '..\/.*main.bicep' = ") | ForEach-Object { $_.LineNumber - 1 })[0] $bicepTestEndIndex = $bicepTestStartIndex do { $bicepTestEndIndex++ } while ($rawContentArray[$bicepTestEndIndex] -notin @('}', '}]')) $rawBicepExample = $rawContentArray[$bicepTestStartIndex..$bicepTestEndIndex] if ($rawBicepExample[-1] -eq '}]') { $rawBicepExample[-1] = '}' } # [2/6] Replace placeholders $serviceShort = ([regex]::Match($rawContent, "(?m)^param serviceShort string = '(.+)'\s*$")).Captures.Groups[1].Value $rawBicepExampleString = ($rawBicepExample | Out-String) $rawBicepExampleString = $rawBicepExampleString -replace '\$\{serviceShort\}', $serviceShort $rawBicepExampleString = $rawBicepExampleString -replace '\$\{namePrefix\}[-|\.|_]?', '' # Replacing with empty to not expose prefix and avoid potential deployment conflicts $rawBicepExampleString = $rawBicepExampleString -replace '(?m):\s*location\s*$', ': ''<location>''' $rawBicepExampleString = $rawBicepExampleString -replace '-\$\{iteration\}', '' # [3/6] Format header, remove scope property & any empty line $rawBicepExample = $rawBicepExampleString -split '\n' $rawBicepExample[0] = "module $moduleNameCamelCase 'br:$($brLink):1.0.0' = {" $rawBicepExample = $rawBicepExample | Where-Object { $_ -notmatch 'scope: *' } | Where-Object { -not [String]::IsNullOrEmpty($_) } # [4/6] Extract param block $rawBicepExampleArray = $rawBicepExample -split '\n' $moduleDeploymentPropertyIndent = ([regex]::Match($rawBicepExampleArray[1], '^(\s+).*')).Captures.Groups[1].Value.Length $paramsStartIndex = ($rawBicepExampleArray | Select-String ("^[\s]{$moduleDeploymentPropertyIndent}params:[\s]*\{") | ForEach-Object { $_.LineNumber - 1 })[0] + 1 if ($rawBicepExampleArray[$paramsStartIndex].Trim() -ne '}') { # Handle case where param block is empty $paramsEndIndex = ($rawBicepExampleArray[($paramsStartIndex + 1)..($rawBicepExampleArray.Count)] | Select-String "^[\s]{$moduleDeploymentPropertyIndent}\}" | ForEach-Object { $_.LineNumber - 1 })[0] + $paramsStartIndex $paramBlock = ($rawBicepExampleArray[$paramsStartIndex..$paramsEndIndex] | Out-String).TrimEnd() } else { $paramBlock = '' $paramsEndIndex = $paramsStartIndex } # [5/6] Convert Bicep parameter block to JSON parameter block to enable processing $conversionInputObject = @{ BicepParamBlock = $paramBlock CurrentFilePath = $testFilePath } $paramsInJSONFormat = ConvertTo-FormattedJSONParameterObject @conversionInputObject # [6/6] Convert JSON parameters back to Bicep and order & format them $conversionInputObject = @{ JSONParameters = $paramsInJSONFormat RequiredParametersList = $RequiredParametersList } $bicepExample = ConvertTo-FormattedBicep @conversionInputObject # --------------------- # # Add Bicep example # # --------------------- # if ($addBicep) { if ([String]::IsNullOrEmpty($paramBlock)) { # Handle case where param block is empty $formattedBicepExample = $rawBicepExample[0..($paramsStartIndex - 1)] + $rawBicepExample[($paramsEndIndex)..($rawBicepExample.Count)] } else { $formattedBicepExample = $rawBicepExample[0..($paramsStartIndex - 1)] + ($bicepExample -split '\n') + $rawBicepExample[($paramsEndIndex + 1)..($rawBicepExample.Count)] } # Remove any dependsOn as it it test specific if ($detected = ($formattedBicepExample | Select-String "^\s{$moduleDeploymentPropertyIndent}dependsOn:\s*\[\s*$" | ForEach-Object { $_.LineNumber - 1 })) { $dependsOnStartIndex = $detected[0] # Find out where the 'dependsOn' ends $dependsOnEndIndex = $dependsOnStartIndex do { $dependsOnEndIndex++ } while ($formattedBicepExample[$dependsOnEndIndex] -notmatch '^\s*\]\s*$') # Cut the 'dependsOn' block out $formattedBicepExample = $formattedBicepExample[0..($dependsOnStartIndex - 1)] + $formattedBicepExample[($dependsOnEndIndex + 1)..($formattedBicepExample.Count)] } # Build result $testFilesContent += @( '', '<details>' '' '<summary>via Bicep module</summary>' '' '```bicep', ($formattedBicepExample | ForEach-Object { "$_" }).TrimEnd(), '```', '', '</details>', '<p>' ) } # -------------------- # # Add JSON example # # -------------------- # if ($addJson) { # [1/2] Get all parameters from the parameter object and order them recursively $orderingInputObject = @{ ParametersJSON = $paramsInJSONFormat | ConvertTo-Json -Depth 99 RequiredParametersList = $RequiredParametersList } $orderedJSONExample = Build-OrderedJSONObject @orderingInputObject # [2/2] Create the final content block $testFilesContent += @( '', '<details>' '' '<summary>via JSON Parameter file</summary>' '' '```json', $orderedJSONExample.Trim() '```', '', '</details>', '<p>' ) } $testFilesContent += @( '' ) $pathIndex++ } foreach ($rawHeader in $usageExampleSectionHeaders) { $navigationHeader = (($rawHeader.header -replace '<\/?.+?>|[^A-Za-z0-9\s-]').Trim() -replace '\s+', '-').ToLower() # Remove any html and non-identifer elements $SectionContent += '- [{0}](#{1})' -f $rawHeader.title, $navigationHeader } $SectionContent += '' $SectionContent += $testFilesContent ###################### ## Built result ## ###################### if ($SectionContent) { if ($PSCmdlet.ShouldProcess('Original file with new template references content', 'Merge')) { return Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $SectionContent -SectionStartIdentifier $SectionStartIdentifier -ContentType 'nextH2' } } else { return $ReadMeFileContent } } <# .SYNOPSIS Generate a table of content section for the given readme file .DESCRIPTION Generate a table of content section for the given readme file .PARAMETER ReadMeFileContent Mandatory. The readme file content array to update .PARAMETER SectionStartIdentifier Optional. The identifier of the 'navigation' section. Defaults to '## Navigation' .EXAMPLE Set-TableOfContent -ReadMeFileContent @('# Title', '', '## Section 1', ...) Update the given readme's '## Navigation' section to reflect the latest file structure #> function Set-TableOfContent { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter(Mandatory = $true)] [object[]] $ReadMeFileContent, [Parameter(Mandatory = $false)] [string] $SectionStartIdentifier = '## Navigation' ) $newSectionContent = [System.Collections.ArrayList]@() $contentPointer = 1 while ($ReadMeFileContent[$contentPointer] -notlike '#*') { $contentPointer++ } $headers = $ReadMeFileContent.Split('\n') | Where-Object { $_ -like '## *' } if ($headers -notcontains $SectionStartIdentifier) { $beforeContent = $ReadMeFileContent[0 .. ($contentPointer - 1)] $afterContent = $ReadMeFileContent[$contentPointer .. ($ReadMeFileContent.Count - 1)] $ReadMeFileContent = $beforeContent + @($SectionStartIdentifier, '') + $afterContent } $headers | Where-Object { $_ -ne $SectionStartIdentifier } | ForEach-Object { $newSectionContent += '- [{0}](#{1})' -f $_.Replace('#', '').Trim(), $_.Replace('#', '').Trim().Replace(' ', '-').Replace('.', '') } # Build result if ($PSCmdlet.ShouldProcess('Original file with new navigation content', 'Merge')) { $updatedFileContent = Merge-FileWithNewContent -oldContent $ReadMeFileContent -newContent $newSectionContent -SectionStartIdentifier $SectionStartIdentifier -contentType 'nextH2' } return $updatedFileContent } <# .SYNOPSIS Initialize the readme file .DESCRIPTION Create the initial skeleton of the section headers, name & description. .PARAMETER ReadMeFilePath Required. The path to the readme file to initialize. .PARAMETER FullModuleIdentifier Required. The full identifier of the module. For example: 'sql/managed-instance/administrator' .PARAMETER TemplateFileContent Mandatory. The template file content object to crawl data from .EXAMPLE Initialize-ReadMe -ReadMeFilePath 'C:/ResourceModules/modules/sql/managed-instances/administrators/readme.md' -FullModuleIdentifier 'sql/managed-instance/administrator' -TemplateFileContent @{ resource = @{}; ... } Initialize the readme of the 'sql/managed-instance/administrator' module #> function Initialize-ReadMe { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $ReadMeFilePath, [Parameter(Mandatory = $true)] [string] $FullModuleIdentifier, [Parameter(Mandatory = $true)] [hashtable] $TemplateFileContent ) $moduleName = $TemplateFileContent.metadata.name $moduleDescription = $TemplateFileContent.metadata.description $formattedResourceType = Get-SpecsAlignedResourceName -ResourceIdentifier $FullModuleIdentifier $hasTests = (Get-ChildItem -Path (Split-Path $ReadMeFilePath) -Recurse -Filter 'main.test.bicep' -File -Force).count -gt 0 $inTemplateResourceType = (Get-NestedResourceList $TemplateFileContent).type | Select-Object -Unique | Where-Object { $_ -match "^$formattedResourceType$" } if (-not $inTemplateResourceType) { Write-Warning "No resource type like [$formattedResourceType] found in template. Falling back to it as identifier." $inTemplateResourceType = $formattedResourceType } # Orphaned readme existing? $orphanedReadMeFilePath = Join-Path (Split-Path $ReadMeFilePath -Parent) 'ORPHANED.md' if (Test-Path $orphanedReadMeFilePath) { $orphanedReadMeContent = Get-Content -Path $orphanedReadMeFilePath | ForEach-Object { "> $_" } } # Moved readme existing? $movedReadMeFilePath = Join-Path (Split-Path $ReadMeFilePath -Parent) 'MOVED-TO-AVM.md' if (Test-Path $movedReadMeFilePath) { $movedReadMeContent = Get-Content -Path $movedReadMeFilePath | ForEach-Object { "> $_" } } $initialContent = @( "# $moduleName ``[$inTemplateResourceType]``", '', ((Test-Path $orphanedReadMeFilePath) ? $orphanedReadMeContent : $null), ((Test-Path $orphanedReadMeFilePath) ? '' : $null), ((Test-Path $movedReadMeFilePath) ? $movedReadMeContent : $null), ((Test-Path $movedReadMeFilePath) ? '' : $null), $moduleDescription, '' '## Resource Types', '' ($hasTests ? '## Usage examples' : $null), ($hasTests ? '' : $null), '## Parameters', '', '## Outputs', '', '## Cross-referenced modules', '', '## Notes' ) | Where-Object { $null -ne $_ } # Filter null values $readMeFileContent = $initialContent return $readMeFileContent } #endregion <# .SYNOPSIS Update/add the readme that matches the given template file .DESCRIPTION Update/add the readme that matches the given template file Supports both ARM & bicep templates. .PARAMETER TemplateFilePath Mandatory. The path to the template to update .PARAMETER TemplateFileContent Optional. The template file content to process. If not provided, the template file content will be read from the TemplateFilePath file. Using this property is useful if you already compiled the bicep template before invoking this function and want to avoid re-compiling it. .PARAMETER ReadMeFilePath Optional. The path to the readme to update. If not provided assumes a 'README.md' file in the same folder as the template .PARAMETER SectionsToRefresh Optional. The sections to update. By default it refreshes all that are supported. Currently supports: 'Resource Types', 'Parameters', 'Outputs', 'Template references' .PARAMETER CrossReferencedModuleList Optional. Cross Module References to consider when refreshing the readme. Can be provided to speed up the generation. If not provided, is fetched by this script. .EXAMPLE Set-ModuleReadMe -TemplateFilePath 'C:\main.bicep' Update the readme in path 'C:\README.md' based on the bicep template in path 'C:\main.bicep' .EXAMPLE Set-ModuleReadMe -TemplateFilePath 'C:/network/load-balancer/main.bicep' -SectionsToRefresh @('Parameters', 'Outputs') Generate the Module ReadMe only for specific sections. Updates only the sections `Parameters` & `Outputs`. Other sections remain untouched. .EXAMPLE Set-ModuleReadMe -TemplateFilePath 'C:/network/load-balancer/main.bicep' -TemplateFileContent @{...} (Re)Generate the readme file for template 'loadBalancer' based on the content provided in the TemplateFileContent parameter .EXAMPLE Set-ModuleReadMe -TemplateFilePath 'C:/network/load-balancer/main.bicep' -ReadMeFilePath 'C:/differentFolder' Generate the Module ReadMe files into a specific folder path .EXAMPLE $templatePaths = (Get-ChildItem 'C:/network' -Filter 'main.bicep' -Recurse).FullName $templatePaths | ForEach-Object -Parallel { . '<PathToRepo>/utilities/tools/Set-ModuleReadMe.ps1' ; Set-ModuleReadMe -TemplateFilePath $_ } Generate the Module ReadMe for any template in a folder path #> function Set-ModuleReadMe { [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter(Mandatory)] [string] $TemplateFilePath, [Parameter(Mandatory = $false)] [hashtable] $TemplateFileContent, [Parameter(Mandatory = $false)] [string] $ReadMeFilePath = (Join-Path (Split-Path $TemplateFilePath -Parent) 'README.md'), [Parameter(Mandatory = $false)] [hashtable] $CrossReferencedModuleList = @{}, [Parameter(Mandatory = $false)] [ValidateSet( 'Resource Types', 'Usage examples', 'Parameters', 'Outputs', 'CrossReferences', 'Template references', 'Navigation' )] [string[]] $SectionsToRefresh = @( 'Resource Types', 'Usage examples', 'Parameters', 'Outputs', 'CrossReferences', 'Template references', 'Navigation' ) ) # Load external functions . (Join-Path $PSScriptRoot 'Get-NestedResourceList.ps1') . (Join-Path $PSScriptRoot 'helper' 'Merge-FileWithNewContent.ps1') . (Join-Path $PSScriptRoot 'helper' 'Get-IsParameterRequired.ps1') . (Join-Path $PSScriptRoot 'helper' 'Get-SpecsAlignedResourceName.ps1') . (Join-Path $PSScriptRoot 'helper' 'ConvertTo-OrderedHashtable.ps1') . (Join-Path (Split-Path $PSScriptRoot -Parent) 'resourcePublish' 'Get-PrivateRegistryRepositoryName.ps1') # Check template & make full path $TemplateFilePath = Resolve-Path -Path $TemplateFilePath -ErrorAction Stop if (-not (Test-Path $TemplateFilePath -PathType 'Leaf')) { throw "[$TemplateFilePath] is no valid file path." } if (-not $TemplateFileContent) { if ((Split-Path -Path $TemplateFilePath -Extension) -eq '.bicep') { $templateFileContent = bicep build $TemplateFilePath --stdout | ConvertFrom-Json -AsHashtable } else { $templateFileContent = ConvertFrom-Json (Get-Content $TemplateFilePath -Encoding 'utf8' -Raw) -ErrorAction 'Stop' -AsHashtable } } if (-not $templateFileContent) { throw "Failed to compile [$TemplateFilePath]" } $moduleRoot = Split-Path $TemplateFilePath -Parent $fullModuleIdentifier = $moduleRoot.Replace('\', '/').split('/modules/')[-1] # Custom modules are modules having the same resource type but different properties based on the name # E.g., web/site/config--appsetting vs web/site/config--authsettingv2 $customModuleSeparator = '--' if ($fullModuleIdentifier.Contains($customModuleSeparator)) { $fullModuleIdentifier = $fullModuleIdentifier.split($customModuleSeparator)[0] } # ===================== # # Preparation steps # # ===================== # # Read original readme, if any. Then delete it to build from scratch if ((Test-Path $ReadMeFilePath) -and -not ([String]::IsNullOrEmpty((Get-Content $ReadMeFilePath -Raw)))) { $readMeFileContent = Get-Content -Path $ReadMeFilePath -Encoding 'utf8' } # Make sure we preserve any manual notes a user might have added in the corresponding section if ($match = $readMeFileContent | Select-String -Pattern '## Notes') { $startIndex = $match.LineNumber $endIndex = $startIndex + 1 while (-not (($endIndex + 1) -gt $readMeFileContent.count) -and $readMeFileContent[($endIndex + 1)] -notlike '## *') { $endIndex++ } $notes = $readMeFileContent[($startIndex - 1)..$endIndex] } else { $notes = @() } # Initialize readme $inputObject = @{ ReadMeFilePath = $ReadMeFilePath FullModuleIdentifier = $FullModuleIdentifier TemplateFileContent = $templateFileContent } $readMeFileContent = Initialize-ReadMe @inputObject # =============== # # Set content # # =============== # if ($SectionsToRefresh -contains 'Resource Types') { # Handle [Resource Types] section # =============================== $inputObject = @{ ReadMeFileContent = $readMeFileContent TemplateFileContent = $templateFileContent } $readMeFileContent = Set-ResourceTypesSection @inputObject } $hasTests = (Get-ChildItem -Path $moduleRoot -Recurse -Filter 'main.test.bicep' -File -Force).count -gt 0 if ($SectionsToRefresh -contains 'Usage examples' -and $hasTests) { # Handle [Usage examples] section # =================================== $inputObject = @{ ModuleRoot = $ModuleRoot FullModuleIdentifier = $fullModuleIdentifier ReadMeFileContent = $readMeFileContent TemplateFileContent = $templateFileContent } $readMeFileContent = Set-UsageExamplesSection @inputObject } if ($SectionsToRefresh -contains 'Parameters') { # Handle [Parameters] section # =========================== $inputObject = @{ ReadMeFileContent = $readMeFileContent TemplateFileContent = $templateFileContent currentFolderPath = (Split-Path $TemplateFilePath -Parent) } $readMeFileContent = Set-ParametersSection @inputObject } if ($SectionsToRefresh -contains 'Outputs') { # Handle [Outputs] section # ======================== $inputObject = @{ ReadMeFileContent = $readMeFileContent TemplateFileContent = $templateFileContent } $readMeFileContent = Set-OutputsSection @inputObject } if ($SectionsToRefresh -contains 'CrossReferences') { # Handle [CrossReferences] section # ======================== if ($CrossReferencedModuleList.Count -eq 0) { . (Join-Path (Get-Item $PSScriptRoot).Parent.Parent 'tools' 'Get-CrossReferencedModuleList.ps1') $CrossReferencedModuleList = Get-CrossReferencedModuleList } $inputObject = @{ ModuleRoot = $ModuleRoot FullModuleIdentifier = $fullModuleIdentifier ReadMeFileContent = $readMeFileContent TemplateFileContent = $templateFileContent CrossReferencedModuleList = $CrossReferencedModuleList } $readMeFileContent = Set-CrossReferencesSection @inputObject } # Handle [Notes] section # ======================== if ($notes) { $readMeFileContent += @( '' ) $readMeFileContent += $notes } if ($SectionsToRefresh -contains 'Navigation') { # Handle [Navigation] section # =================================== $inputObject = @{ ReadMeFileContent = $readMeFileContent } $readMeFileContent = Set-TableOfContent @inputObject } Write-Verbose 'New content:' Write-Verbose '============' Write-Verbose ($readMeFileContent | Out-String) if (Test-Path $ReadMeFilePath) { if ($PSCmdlet.ShouldProcess("File in path [$ReadMeFilePath]", 'Overwrite')) { Set-Content -Path $ReadMeFilePath -Value $readMeFileContent -Force -Encoding 'utf8' } Write-Verbose "File [$ReadMeFilePath] updated" -Verbose } else { if ($PSCmdlet.ShouldProcess("File in path [$ReadMeFilePath]", 'Create')) { $null = New-Item -Path $ReadMeFilePath -Value ($readMeFileContent | Out-String) -Force } Write-Verbose "File [$ReadMeFilePath] created" -Verbose } }