AzureCAF.psm1

function Export-CafResourceType {
    <#
    .SYNOPSIS
        Exports the Cloud Adoption Framework naming rules for one or more resource types to a CSV or
        Markdown file.
    .DESCRIPTION
        Writes the ResourceType/Resource/Slug/Scope/length/casing fields returned by Get-CafResourceType to
        a file, as a reference table a team can review without running PowerShell - e.g. to print, link
        from a wiki page, or attach to a design doc. The format is chosen from -Path's extension: .csv
        or .md (.markdown also works).
 
        Accepts the same objects Get-CafResourceType returns on the pipeline, so you can export a filtered
        subset; with nothing piped in, it exports every resource type the module knows about.
    .PARAMETER Path
        Where to write the export. Must end in .csv, .md or .markdown.
    .PARAMETER InputObject
        Resource type definitions to export, as returned by Get-CafResourceType. Defaults to every
        resource type the module knows about (equivalent to piping in `Get-CafResourceType`) if nothing is
        supplied.
    .PARAMETER Force
        Overwrite -Path if it already exists.
    .EXAMPLE
        Export-CafResourceType -Path ./resource-types.md
 
        Writes a Markdown table of every resource type the module knows about.
    .EXAMPLE
        Get-CafResourceType -ResourceType *storage* | Export-CafResourceType -Path ./storage-types.csv
 
        Exports just the storage-related resource types to CSV.
    .LINK
        https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Path,

        [Parameter(ValueFromPipeline)]
        [object[]]
        $InputObject,

        [switch]
        $Force
    )

    begin {
        $Items = [System.Collections.Generic.List[object]]::new()
    }

    process {
        foreach ($Item in $InputObject) {
            $Items.Add($Item)
        }
    }

    end {
        $Extension = [System.IO.Path]::GetExtension($Path).ToLowerInvariant()

        if ($Extension -notin '.csv', '.md', '.markdown') {
            Write-Error "'$Path' must end in .csv, .md or .markdown."
            return
        }

        if ($Items.Count -eq 0) {
            $Items.AddRange(@(Get-CafResourceType))
        }

        if ((Test-Path -Path $Path) -and -not $Force) {
            Write-Error "'$Path' already exists. Specify -Force to overwrite it."
            return
        }

        if (-not $PSCmdlet.ShouldProcess($Path, "Export $($Items.Count) resource type(s)")) {
            return
        }

        $Properties = 'ResourceType', 'Resource', 'Slug', 'Scope', 'MinLength', 'MaxLength', 'Lowercase', 'Dashes'

        if ($Extension -eq '.csv') {
            $Items | Select-Object -Property $Properties | Export-Csv -Path $Path -NoTypeInformation
        }
        else {
            $Lines = [System.Collections.Generic.List[string]]::new()
            $Lines.Add("| $($Properties -join ' | ') |")
            $Lines.Add("| $(($Properties | ForEach-Object { '---' }) -join ' | ') |")

            foreach ($Item in $Items) {
                $Cells = foreach ($Property in $Properties) {
                    "$($Item.$Property)".Replace('|', '\|')
                }
                $Lines.Add("| $($Cells -join ' | ') |")
            }

            Set-Content -Path $Path -Value $Lines
        }

        Get-Item -Path $Path
    }
}
function Get-CafResourceName {
    <#
    .SYNOPSIS
        Generates an Azure resource name that follows the Cloud Adoption Framework naming convention.
    .DESCRIPTION
        Builds a resource name from an optional prefix, the Cloud Adoption Framework recommended
        abbreviation for the given Azure resource type, and an optional suffix. The result is checked
        against the resource type's length limit and allowed characters, and is trimmed and/or converted
        to lowercase automatically if the resource type requires it.
 
        Accepts pipeline input by property name, so a list of resource specs (e.g. imported from a CSV
        or a PSD1 describing a whole deployment) can be piped straight through to generate every name
        for a project in one batch - see the pipeline example below.
 
        Use Get-CafResourceType to discover the ResourceType/Resource values this cmdlet accepts. Use
        -Verbose to see the length-budget reasoning (reserved segments, trimming decisions) behind a
        given result.
    .PARAMETER ResourceType
        The ARM resource type to generate a name for, e.g. 'Microsoft.Compute/virtualMachines'. Matches
        as a substring by default, so 'virtualMachines' also works - as long as it narrows down to a
        single resource type, or you also specify -Resource. Tab completion lists the resource types
        known to the module. Normally used on its own (or -Alias, or -Resource, used instead); takes
        precedence if more than one happens to be specified, e.g. by piping in an object that has
        ResourceType, Alias and Resource properties all describing the same entry.
    .PARAMETER Alias
        The Cloud Adoption Framework abbreviation to generate a name for, e.g. 'vmss', if you already
        know it, used as a short alias for the ARM resource type. Matches as a substring by default,
        same as -ResourceType, and also accepts -Slug as an alias (so piping Get-CafResourceType's output
        straight in binds to this by its Slug property). Normally used on its own, instead of
        -ResourceType.
    .PARAMETER Resource
        The friendly name of the resource to generate, e.g. 'Key vault' - every such name in the
        module's data is unique, so on its own this works as a third alternative to -ResourceType/
        -Alias (also matching as a substring, so 'vault' alone works too). It also doubles as a
        disambiguator when combined with -ResourceType/-Alias, for the handful of ARM types used by
        more than one kind of resource (for example 'Microsoft.Web/sites' covers both a "Web app" and a
        "Function app", with different recommended abbreviations) - required in that case, since
        -ResourceType/-Alias alone would otherwise be ambiguous.
    .PARAMETER Prefix
        One or more strings to prepend to the resource name, in order, e.g. a company name, environment,
        or region. Falls back to -ConventionPath's value if not specified.
    .PARAMETER Suffix
        One or more strings to append to the resource name, in order. Falls back to -ConventionPath's
        value if not specified.
    .PARAMETER Separator
        The character(s) used to join the prefix, abbreviation and suffix segments together. Ignored for
        resource types that don't permit a separator character, in which case the segments are
        concatenated directly. Defaults to '-', or to -ConventionPath's value if it specifies one.
    .PARAMETER InstanceNumber
        An instance number to append to the resource name, for differentiating otherwise-identical
        resources (e.g. 'vm-sql-test-001', 'vm-sql-test-002' in the Cloud Adoption Framework's own naming
        examples). Zero-padded to -InstanceNumberPadding digits and reserved out of the resource type's
        maximum length before the rest of the name is trimmed, so it's never cut short.
    .PARAMETER InstanceNumberPadding
        The minimum number of digits to zero-pad -InstanceNumber to, e.g. the default of 3 formats '1'
        as '001'. A number that's already at least this many digits long is left as-is. Ignored unless
        -InstanceNumber is specified. Falls back to -ConventionPath's value if not specified.
    .PARAMETER Count
        Generates this many names in one call, with sequential instance numbers, instead of just one.
        Starts from -InstanceNumber if specified (e.g. -InstanceNumber 5 -Count 3 produces 005, 006, 007),
        or from 1 otherwise. Implies an instance number even if -InstanceNumber isn't specified, since
        otherwise every generated name would be identical. Each generated name gets its own random
        suffix if -RandomSuffixLength is also specified.
    .PARAMETER RandomSuffixLength
        Appends this many random lowercase alphanumeric characters to the end of the name, for
        scenarios that require a globally unique name (such as a storage account or key vault). Always
        lowercase, even for a resource type that otherwise allows uppercase - the suffix exists for
        uniqueness, not communication, and doesn't need the extra entropy. The characters are reserved
        out of the resource type's maximum length before the rest of the name is trimmed, so the random
        suffix is never cut short. Falls back to -ConventionPath's value if not specified.
    .PARAMETER RandomSuffixSeed
        Seeds the random number generator used by -RandomSuffixLength, so the same seed (with the same
        other parameters) always produces the same random characters - useful if you need to generate
        the same name idempotently, e.g. across repeated deployments. Ignored unless -RandomSuffixLength
        is specified.
    .PARAMETER ConventionPath
        Path to a JSON file of organizational naming-convention defaults for -Prefix, -Suffix,
        -Separator, -InstanceNumberPadding and -RandomSuffixLength, created with
        New-CafResourceNamingConvention. Any of those you also specify explicitly (including via the
        pipeline) take precedence over the file - this just saves retyping the same org-standard values
        on every call.
    .PARAMETER NoClean
        By default, characters that aren't valid for the given resource type are stripped from -Prefix
        and -Suffix before the name is built. Specify -NoClean to pass them through unmodified instead
        (the generated name will then fail validation if it contains invalid characters).
    .PARAMETER AsObject
        Returns an object with Name, ResourceType, Resource, Slug, InstanceNumber and RandomSuffix
        properties instead of just the name string - useful for telling generated names apart when
        batch-generating across different resource types and/or -Count, or for exporting the result
        (e.g. to CSV) alongside the values that produced it. InstanceNumber/RandomSuffix are $null when
        -InstanceNumber/-Count or -RandomSuffixLength weren't in play for that name.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso, prod -Suffix 01
 
        Generates a virtual machine name such as 'contoso-prod-vm-01'.
    .EXAMPLE
        Get-CafResourceName -ResourceType KeyVault -Prefix contoso
 
        -ResourceType matches as a substring, so this is equivalent to specifying the full
        'Microsoft.KeyVault/vaults' ARM type. If a substring matches more than one resource type,
        narrow it down with a longer -ResourceType value and/or -Resource.
    .EXAMPLE
        Get-CafResourceName -Alias vmss -Prefix contoso
 
        If you already know the Cloud Adoption Framework abbreviation, -Alias (or its -Slug alias) is a
        shorter alternative to -ResourceType.
    .EXAMPLE
        Get-CafResourceName -Resource 'Key vault' -Prefix contoso
 
        -Resource alone works too, since every friendly name is unique - a third alternative to
        -ResourceType/-Alias for when you only remember the resource's plain-English name.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso, prod -InstanceNumber 1
 
        Generates 'contoso-prod-vm-001' - -InstanceNumber is zero-padded to 3 digits by default, matching
        the Cloud Adoption Framework's own example names such as 'vm-sql-test-001'.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso, prod -Count 3
 
        Generates 'contoso-prod-vm-001', 'contoso-prod-vm-002' and 'contoso-prod-vm-003' in one call.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Storage/storageAccounts -Prefix contoso -RandomSuffixLength 6
 
        Generates a storage account name such as 'contosostabcdef', with 6 random characters appended to
        help ensure the globally-unique name doesn't collide with one that already exists.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Storage/storageAccounts -Prefix contoso -RandomSuffixLength 6 -RandomSuffixSeed 42
 
        Generates the same storage account name every time it's run, since the random suffix is seeded.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Web/sites -Resource 'Function app' -Prefix contoso, prod
 
        Generates a function app name. -Resource is required here because 'Microsoft.Web/sites' is also
        used for web apps, which use a different abbreviation.
    .EXAMPLE
        Get-CafResourceType -Slug kv | Get-CafResourceName -Prefix contoso -RandomSuffixLength 4
 
        Pipes the resource type definition for the 'kv' (Key Vault) abbreviation straight into
        Get-CafResourceName.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -Prefix contoso -ConventionPath ./contoso.json
 
        Uses -Prefix explicitly, but falls back to whatever -Suffix/-Separator/etc. are saved in
        contoso.json (see New-CafResourceNamingConvention) for anything not specified here.
    .EXAMPLE
        Import-Csv ./resources.csv | Get-CafResourceName
 
        Batch-generates a name for every row of resources.csv, which needs at least a ResourceType or
        Alias column, and may also have Resource/Prefix/Suffix/Separator/InstanceNumber columns.
    .EXAMPLE
        Import-Csv ./resources.csv | Get-CafResourceName -AsObject | Export-Csv ./generated-names.csv
 
        -AsObject keeps the ResourceType/Resource/Slug/InstanceNumber/RandomSuffix that produced each
        name alongside it, rather than returning bare strings that lose track of which input row (or
        which -Count iteration) each one came from.
    .LINK
        https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $ResourceType,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Slug')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Alias,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Resource,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string[]]
        $Prefix,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string[]]
        $Suffix,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Separator = '-',

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, [int]::MaxValue)]
        [int]
        $InstanceNumber,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(1, 10)]
        [int]
        $InstanceNumberPadding = 3,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(1, 1000)]
        [int]
        $Count = 1,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, 24)]
        [int]
        $RandomSuffixLength = 0,

        [Parameter(ValueFromPipelineByPropertyName)]
        [int]
        $RandomSuffixSeed,

        [string]
        $ConventionPath,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $NoClean,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $AsObject
    )

    begin {
        function Get-CleanString {
            param (
                [string[]]$InputObject,
                [string]$Pattern
            )

            foreach ($Str in $InputObject) {
                $Str -replace $Pattern, ''
            }
        }

        function Get-TrimString {
            param (
                [string]$InputObject,
                [int]$MaxLength
            )

            if ($InputObject.Length -gt $MaxLength) {
                $InputObject.Substring(0, $MaxLength)
            }
            else {
                $InputObject
            }
        }

        $Convention = $null
        if ($ConventionPath) {
            if (-not (Test-Path -Path $ConventionPath -PathType Leaf)) {
                $PSCmdlet.ThrowTerminatingError(
                    [System.Management.Automation.ErrorRecord]::new(
                        [System.IO.FileNotFoundException]::new("Naming convention file not found: '$ConventionPath'."),
                        'ConventionFileNotFound',
                        [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                        $ConventionPath
                    )
                )
            }

            try {
                $Convention = Get-Content -Path $ConventionPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
                Write-Verbose "Loaded naming convention from '$ConventionPath'."
            }
            catch {
                $PSCmdlet.ThrowTerminatingError($_)
            }
        }
    }

    process {
        if (-not $ResourceType -and -not $Alias -and -not $Resource) {
            Write-Error 'Specify -ResourceType, -Alias or -Resource to identify the resource type.'
            return
        }

        $definition = Resolve-CafResourceDefinition -ResourceType $ResourceType -Alias $Alias -Resource $Resource

        if (-not $definition) {
            return
        }

        Write-Verbose "Resolved to $($definition.ResourceType) [$($definition.Resource)] (slug '$($definition.Slug)', max length $($definition.MaxLength), dashes $($definition.Dashes), lowercase $($definition.Lowercase))."

        $EffectivePrefix = if ($PSBoundParameters.ContainsKey('Prefix')) { $Prefix } else { $Convention.Prefix }
        $EffectiveSuffix = if ($PSBoundParameters.ContainsKey('Suffix')) { $Suffix } else { $Convention.Suffix }
        $EffectiveSeparator = if ($PSBoundParameters.ContainsKey('Separator')) { $Separator } elseif ($Convention.Separator) { $Convention.Separator } else { $Separator }
        $EffectiveInstanceNumberPadding = if ($PSBoundParameters.ContainsKey('InstanceNumberPadding')) { $InstanceNumberPadding } elseif ($Convention.InstanceNumberPadding) { $Convention.InstanceNumberPadding } else { $InstanceNumberPadding }
        $EffectiveRandomSuffixLength = if ($PSBoundParameters.ContainsKey('RandomSuffixLength')) { $RandomSuffixLength } elseif ($null -ne $Convention.RandomSuffixLength) { $Convention.RandomSuffixLength } else { $RandomSuffixLength }

        if ($EffectiveInstanceNumberPadding -lt 1 -or $EffectiveInstanceNumberPadding -gt 10) {
            Write-Error "InstanceNumberPadding from the naming convention must be between 1 and 10, but was $EffectiveInstanceNumberPadding."
            return
        }

        if ($EffectiveRandomSuffixLength -lt 0 -or $EffectiveRandomSuffixLength -gt 24) {
            Write-Error "RandomSuffixLength from the naming convention must be between 0 and 24, but was $EffectiveRandomSuffixLength."
            return
        }

        if ($NoClean) {
            $CleanPrefix = $EffectivePrefix
            $CleanSuffix = $EffectiveSuffix
        }
        else {
            $CleanPrefix = Get-CleanString -InputObject $EffectivePrefix -Pattern $definition.InvalidCharsRegex
            $CleanSuffix = Get-CleanString -InputObject $EffectiveSuffix -Pattern $definition.InvalidCharsRegex
        }

        $ActualSeparator = if ($definition.Dashes) { $EffectiveSeparator } else { '' }

        $HasInstanceNumber = $PSBoundParameters.ContainsKey('InstanceNumber') -or $Count -gt 1
        $StartInstanceNumber = if ($PSBoundParameters.ContainsKey('InstanceNumber')) { $InstanceNumber } else { 1 }
        $EndInstanceNumber = $StartInstanceNumber + $Count - 1

        # Reserve space based on the longest instance number in the sequence, so the trimmed base name
        # (prefix/slug/suffix) is the same length for every generated name, not just the first one.
        $InstanceSegment = if ($HasInstanceNumber) { $EndInstanceNumber.ToString().PadLeft($EffectiveInstanceNumberPadding, '0') } else { $null }

        $reservedLength = 0
        $reservedDescriptions = @()

        if ($InstanceSegment) {
            $reservedLength += $InstanceSegment.Length + $ActualSeparator.Length
            $reservedDescriptions += 'the instance number'
        }

        if ($EffectiveRandomSuffixLength -gt 0) {
            $reservedLength += $EffectiveRandomSuffixLength + $ActualSeparator.Length
            $reservedDescriptions += 'the random suffix'
        }

        $mainBudget = $definition.MaxLength - $reservedLength

        if ($reservedDescriptions.Count -gt 0) {
            Write-Verbose "Reserving $reservedLength character(s) for $($reservedDescriptions -join ' and '); $mainBudget of $($definition.MaxLength) remain for the prefix/slug/suffix."
        }
        else {
            Write-Verbose "No reserved segments; the full $mainBudget character limit is available for the prefix/slug/suffix."
        }

        if ($mainBudget -lt $definition.Slug.Length) {
            Write-Error "Not enough room within the $($definition.MaxLength) character limit for $($definition.ResourceType): '$($definition.Slug)' needs $($definition.Slug.Length) characters, but only $mainBudget are available after reserving space for $($reservedDescriptions -join ' and ')."
            return
        }

        $resourceArr = @($CleanPrefix) + @($definition.Slug) + @($CleanSuffix) | Where-Object { $_ }

        $resourceName = $resourceArr -join $ActualSeparator
        Write-Verbose "Joined '$($resourceArr -join "', '")' with separator '$ActualSeparator' -> '$resourceName' ($($resourceName.Length) characters)."

        if ($resourceName.Length -gt $mainBudget) {
            $beforeTrimLength = $resourceName.Length
            $resourceName = Get-TrimString -InputObject $resourceName -MaxLength $mainBudget
            $reservedNote = if ($reservedDescriptions.Count -gt 0) { " (after reserving space for $($reservedDescriptions -join ' and '))" } else { '' }
            Write-Warning "Resource name has been trimmed to $mainBudget characters due to the $($definition.MaxLength) character max length restriction$reservedNote."
            Write-Verbose "Trimmed from $beforeTrimLength to $mainBudget characters -> '$resourceName'."
        }

        # A single shared Random, created once per pipeline item rather than per generated name, so a
        # -RandomSuffixSeed produces a reproducible sequence of distinct suffixes across the whole -Count batch.
        $Random = if ($EffectiveRandomSuffixLength -gt 0) {
            if ($PSBoundParameters.ContainsKey('RandomSuffixSeed')) { [System.Random]::new($RandomSuffixSeed) } else { [System.Random]::new() }
        }
        else {
            $null
        }
        $CharPool = 'abcdefghijklmnopqrstuvwxyz0123456789'

        for ($InstanceCounter = $StartInstanceNumber; $InstanceCounter -le $EndInstanceNumber; $InstanceCounter++) {
            $FinalName = $resourceName

            if ($HasInstanceNumber) {
                $CurrentInstanceSegment = $InstanceCounter.ToString().PadLeft($EffectiveInstanceNumberPadding, '0')
                $FinalName = $FinalName + $ActualSeparator + $CurrentInstanceSegment
                Write-Verbose "Appended instance segment '$CurrentInstanceSegment' -> '$FinalName'."
            }

            if ($EffectiveRandomSuffixLength -gt 0) {
                # Always lowercase alphanumeric, even for resource types that allow uppercase - the
                # random suffix exists for uniqueness, not communication, and 36^RandomSuffixLength is
                # already far more entropy than a uniqueness suffix needs.
                $RandomSuffix = -join (1..$EffectiveRandomSuffixLength | ForEach-Object { $CharPool[$Random.Next($CharPool.Length)] })
                $FinalName = $FinalName + $ActualSeparator + $RandomSuffix
                Write-Verbose "Appended random suffix '$RandomSuffix' -> '$FinalName'."
            }

            if ($definition.Lowercase -and $FinalName -cmatch '[A-Z]') {
                $FinalName = $FinalName.ToLower()
                Write-Warning 'Resource name has been converted to lower case due to naming requirement.'
                Write-Verbose "Converted to lowercase -> '$FinalName'."
            }

            if ($FinalName -notmatch $definition.ValidationRegex) {
                Write-Error "The generated name '$FinalName' is not valid for $($definition.ResourceType) (pattern: $($definition.ValidationRegex))."
            }
            elseif ($AsObject) {
                Write-Verbose "Final result: '$FinalName'."
                [pscustomobject]@{
                    Name           = $FinalName
                    ResourceType   = $definition.ResourceType
                    Resource       = $definition.Resource
                    Slug           = $definition.Slug
                    InstanceNumber = if ($HasInstanceNumber) { $InstanceCounter } else { $null }
                    RandomSuffix   = if ($EffectiveRandomSuffixLength -gt 0) { $RandomSuffix } else { $null }
                }
            }
            else {
                Write-Verbose "Final result: '$FinalName'."
                $FinalName
            }
        }
    }
}
function Get-CafResourceType {
    <#
    .SYNOPSIS
        Looks up the Cloud Adoption Framework naming rules for one or more Azure resource types.
    .DESCRIPTION
        Searches the module's bundled resourceDefinition.json for Azure resource types and returns their
        recommended CAF abbreviation along with the naming scope, length limits and casing rules that
        Get-CafResourceName uses to build a compliant name.
 
        This is a local, offline lookup against a static data set bundled with the module. It's not a
        replacement for Get-AzResourceProvider (from the Az.Resources module): that cmdlet calls the live
        Azure Resource Manager API to report which resource providers/types are registered and available
        in a subscription, and requires being signed in to Azure. Get-CafResourceType has no dependency on
        Az.Resources or an Azure session - it only answers "what's the recommended naming convention for
        this resource type", not "is this resource type available to me right now".
    .PARAMETER ResourceType
        The ARM resource type to search for, e.g. 'Microsoft.Compute/virtualMachines'. Matches as a
        substring by default, e.g. 'virtualMachines' or 'compute' both match it too - pass an explicit
        wildcard pattern (e.g. 'Microsoft.Compute/*') for tighter control, such as anchoring to the
        start. If omitted, all resource types are returned (optionally filtered by -Resource/-Alias).
    .PARAMETER Resource
        The friendly resource name to search for, e.g. 'Virtual machine'. Matches as a substring by
        default, same as -ResourceType. Useful to disambiguate ARM resource types that cover more than
        one kind of resource (for example 'Microsoft.Compute/disks' covers both a "Managed disk (OS)"
        and a "Managed disk (data)").
    .PARAMETER Alias
        The Cloud Adoption Framework abbreviation to search for, e.g. 'vm', used as a short alias for
        the ARM resource type. Matches as a substring by default, same as -ResourceType/-Resource, and
        also accepts -Slug as an alias.
    .EXAMPLE
        Get-CafResourceType -ResourceType Microsoft.Compute/virtualMachines
 
        Returns the naming rule for an Azure virtual machine.
    .EXAMPLE
        Get-CafResourceType -ResourceType storageAccount
 
        Finds resource types whose ARM type contains 'storageAccount'.
    .EXAMPLE
        Get-CafResourceType -Alias vm
 
        Finds the resource type(s) that use the 'vm' abbreviation.
    .EXAMPLE
        Get-CafResourceType
 
        Lists every resource type known to the module.
    .LINK
        https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]
        $ResourceType,

        [string]
        $Resource,

        [Alias('Slug')]
        [string]
        $Alias
    )

    $definitions = @(Get-CafResourceDefinition)

    if ($ResourceType) {
        $definitions = Select-CafMatch -InputObject $definitions -Property ResourceType -Value $ResourceType
    }

    if ($Resource) {
        $definitions = Select-CafMatch -InputObject $definitions -Property Resource -Value $Resource
    }

    if ($Alias) {
        $definitions = Select-CafMatch -InputObject $definitions -Property Slug -Value $Alias
    }

    $definitions | Select-Object ResourceType, Resource, Slug, Scope, MinLength, MaxLength, Lowercase, Dashes
}
function New-CafResourceNamingConvention {
    <#
    .SYNOPSIS
        Saves a reusable set of Get-CafResourceName defaults (prefix, suffix, separator, etc.) to a JSON
        file, so your organization's naming convention doesn't need to be retyped on every call.
    .DESCRIPTION
        Writes a small JSON file that Get-CafResourceName's -ConventionPath reads defaults from for
        -Prefix, -Suffix, -Separator, -InstanceNumberPadding and -RandomSuffixLength. Anything you pass
        explicitly to Get-CafResourceName still takes precedence over the file - this is just a way to
        avoid repeating the same organization-standard values (company name, environment, region, your
        preferred separator/padding) on every call.
    .PARAMETER Path
        Where to save the convention file, e.g. './contoso.json'.
    .PARAMETER Prefix
        One or more strings to use as the default -Prefix, e.g. a company name, environment, or region -
        see the naming components guide this links to for what's commonly included.
    .PARAMETER Suffix
        One or more strings to use as the default -Suffix.
    .PARAMETER Separator
        The default -Separator. Omit to leave this to Get-CafResourceName's own default ('-').
    .PARAMETER InstanceNumberPadding
        The default -InstanceNumberPadding. Omit to leave this to Get-CafResourceName's own default (3).
    .PARAMETER RandomSuffixLength
        The default -RandomSuffixLength. Omit to leave this to Get-CafResourceName's own default (0, i.e.
        no random suffix unless a call asks for one explicitly).
    .PARAMETER Force
        Overwrite -Path if it already exists.
    .EXAMPLE
        New-CafResourceNamingConvention -Path ./contoso.json -Prefix contoso, prod -Separator '-' -InstanceNumberPadding 2
 
        Saves a convention so that Get-CafResourceName -ConventionPath ./contoso.json applies that prefix,
        separator and padding by default.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Compute/virtualMachines -ConventionPath ./contoso.json -InstanceNumber 1
 
        Uses the saved convention, overriding only -InstanceNumber for this particular call.
    .LINK
        https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-naming#choose-naming-components
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]
        $Path,

        [string[]]
        $Prefix,

        [string[]]
        $Suffix,

        [string]
        $Separator,

        [ValidateRange(1, 10)]
        [int]
        $InstanceNumberPadding,

        [ValidateRange(0, 24)]
        [int]
        $RandomSuffixLength,

        [switch]
        $Force
    )

    if ((Test-Path -Path $Path) -and -not $Force) {
        Write-Error "'$Path' already exists. Specify -Force to overwrite it."
        return
    }

    $Convention = [ordered]@{}

    if ($PSBoundParameters.ContainsKey('Prefix')) { $Convention.Prefix = $Prefix }
    if ($PSBoundParameters.ContainsKey('Suffix')) { $Convention.Suffix = $Suffix }
    if ($PSBoundParameters.ContainsKey('Separator')) { $Convention.Separator = $Separator }
    if ($PSBoundParameters.ContainsKey('InstanceNumberPadding')) { $Convention.InstanceNumberPadding = $InstanceNumberPadding }
    if ($PSBoundParameters.ContainsKey('RandomSuffixLength')) { $Convention.RandomSuffixLength = $RandomSuffixLength }

    if ($Convention.Count -eq 0) {
        Write-Error 'Specify at least one of -Prefix, -Suffix, -Separator, -InstanceNumberPadding or -RandomSuffixLength.'
        return
    }

    if ($PSCmdlet.ShouldProcess($Path, 'Create naming convention file')) {
        $Convention | ConvertTo-Json | Set-Content -Path $Path
        Get-Item -Path $Path
    }
}
function Test-CafResourceName {
    <#
    .SYNOPSIS
        Tests whether a name complies with the Cloud Adoption Framework naming rules for an Azure
        resource type.
    .DESCRIPTION
        Checks a resource name against the same rules Get-CafResourceName enforces when building one:
        minimum/maximum length, required casing, whether the resource type allows a dash, and the
        overall set of valid characters. Returns an object showing the pass/fail result of each rule, a
        plain-English Reason when it's invalid, and an overall IsValid result - or just the overall
        true/false with -Quiet.
 
        IsValid is the authoritative result (the same validation pattern Get-CafResourceName itself checks
        names against); the other fields are a diagnostic breakdown to help pinpoint why a name failed.
        For a small number of resource types the breakdown can't perfectly add up to IsValid - for
        example a character that's only disallowed as the *first* character of a name is flagged as
        invalid by -ValidCharacters wherever it appears, even though IsValid may still be true if it
        appears elsewhere. Treat IsValid as the source of truth.
    .PARAMETER Name
        The resource name to test, e.g. 'contoso-prod-vm-001'.
    .PARAMETER ResourceType
        The ARM resource type to test the name against, e.g. 'Microsoft.Compute/virtualMachines'.
        Matches as a substring by default. Normally used on its own (or -Alias, or -Resource, used
        instead); takes precedence if more than one happens to be specified, e.g. by piping in an
        object that has ResourceType, Alias and Resource properties all describing the same entry.
    .PARAMETER Alias
        The Cloud Adoption Framework abbreviation to test the name against, e.g. 'vmss', used as a
        short alias for the ARM resource type. Matches as a substring by default, and also accepts
        -Slug as an alias. Normally used on its own, instead of -ResourceType.
    .PARAMETER Resource
        The friendly name of the resource to test against, e.g. 'Key vault' - every such name in the
        module's data is unique, so on its own this works as a third alternative to -ResourceType/
        -Alias (also matching as a substring). It also doubles as a disambiguator when combined with
        -ResourceType/-Alias, for the handful of ARM types used by more than one kind of resource (for
        example 'Microsoft.Web/sites' covers both a "Web app" and a "Function app") - required in that
        case, since -ResourceType/-Alias alone would otherwise be ambiguous.
    .PARAMETER Quiet
        Return only an overall $true/$false instead of the full breakdown object.
    .EXAMPLE
        Test-CafResourceName -Name 'contoso-prod-vm-001' -ResourceType Microsoft.Compute/virtualMachines
 
        Returns an object showing which naming rules the name passes or fails, and an overall IsValid.
    .EXAMPLE
        (Test-CafResourceName -Name 'CONTOSO' -ResourceType Microsoft.Storage/storageAccounts).Reason
 
        Returns 'Name must be all lowercase for this resource type.' - Reason explains an invalid
        result in plain English; it's $null when IsValid is $true.
    .EXAMPLE
        Test-CafResourceName -Name 'Contoso_VM!' -Alias vm -Quiet
 
        Returns $false - the name is uppercase, contains an underscore and an exclamation mark, none of
        which are valid for a virtual machine name.
    .EXAMPLE
        Test-CafResourceName -Name 'contoso-kv' -Resource 'Key vault'
 
        -Resource alone works too, since every friendly name is unique - a third alternative to
        -ResourceType/-Alias for when you only remember the resource's plain-English name.
    .EXAMPLE
        Get-CafResourceName -ResourceType Microsoft.Storage/storageAccounts -Prefix contoso -RandomSuffixLength 6 |
            Test-CafResourceName -ResourceType Microsoft.Storage/storageAccounts -Quiet
 
        Round-trips a name generated by Get-CafResourceName back through Test-CafResourceName.
    .LINK
        https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-abbreviations
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter(Position = 1, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $ResourceType,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Slug')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Alias,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Resource,

        [switch]
        $Quiet
    )

    process {
        if (-not $ResourceType -and -not $Alias -and -not $Resource) {
            Write-Error 'Specify -ResourceType, -Alias or -Resource to identify the resource type.'
            return
        }

        $definition = Resolve-CafResourceDefinition -ResourceType $ResourceType -Alias $Alias -Resource $Resource

        if (-not $definition) {
            return
        }

        $MinLength = $Name.Length -ge $definition.MinLength
        $MaxLength = $Name.Length -le $definition.MaxLength
        $Casing = -not ($definition.Lowercase -and $Name -cmatch '[A-Z]')
        $Dashes = $definition.Dashes -or $Name -cnotmatch '-'
        $ValidCharacters = $Name -cnotmatch $definition.InvalidCharsRegex
        $IsValid = $Name -cmatch $definition.ValidationRegex

        if ($Quiet) {
            return $IsValid
        }

        $Reasons = [System.Collections.Generic.List[string]]::new()

        if ($IsValid) {
            # Even if every breakdown field is individually true, IsValid is the authoritative check -
            # there's nothing to explain when it passed, regardless of what the breakdown says.
        }
        else {
            if (-not $MinLength) {
                $Reasons.Add("Name is $($Name.Length) character$(if ($Name.Length -ne 1) { 's' }), which is shorter than the $($definition.MinLength) character minimum.")
            }

            if (-not $MaxLength) {
                $Reasons.Add("Name is $($Name.Length) characters, which exceeds the $($definition.MaxLength) character maximum by $($Name.Length - $definition.MaxLength).")
            }

            if (-not $Casing) {
                $Reasons.Add('Name must be all lowercase for this resource type.')
            }

            if (-not $Dashes) {
                $Reasons.Add("Name contains a dash ('-'), which this resource type does not allow.")
            }

            if (-not $ValidCharacters) {
                $Reasons.Add('Name contains characters that are not allowed for this resource type.')
            }

            if ($Reasons.Count -eq 0) {
                # None of the simple per-rule checks explain it - some resource types have additional
                # positional rules (e.g. must start/end with a letter) that aren't broken out above.
                $Reasons.Add("Name does not match the required pattern for this resource type (pattern: $($definition.ValidationRegex)).")
            }
        }

        [pscustomobject]@{
            Name            = $Name
            ResourceType    = $definition.ResourceType
            Resource        = $definition.Resource
            IsValid         = $IsValid
            Reason          = if ($Reasons.Count -gt 0) { $Reasons -join ' ' } else { $null }
            MinLength       = $MinLength
            MaxLength       = $MaxLength
            Casing          = $Casing
            Dashes          = $Dashes
            ValidCharacters = $ValidCharacters
        }
    }
}
function Get-CafResourceDefinition {
    <#
    .SYNOPSIS
        Returns the cached set of CAF resource naming definitions.
    .DESCRIPTION
        Loads resourceDefinition.json from the module root the first time it's needed and caches it for
        the lifetime of the module session, so repeated calls to Get-CafResourceName/Get-CafResourceType don't
        re-read and re-parse the file from disk every time.
    .EXAMPLE
        Get-CafResourceDefinition
    #>

    [CmdletBinding()]
    param()

    if (-not $Script:CafResourceDefinitions) {
        $Script:CafResourceDefinitions = Get-Content -Path $Script:ResourceDefinitionPath -Raw | ConvertFrom-Json
    }

    $Script:CafResourceDefinitions
}
function Get-CafWildcardPattern {
    <#
    .SYNOPSIS
        Turns a plain string into a partial ('contains') wildcard pattern.
    .DESCRIPTION
        Used by Get-CafResourceName/Get-CafResourceType so that -ResourceType and -Resource match as a
        substring by default (e.g. 'virtualMachines' matches 'Microsoft.Compute/virtualMachines'),
        while still respecting an explicit wildcard pattern (e.g. '*storageAccount*' or 'disk?') if the
        caller already supplied one, rather than wrapping it a second time.
    .EXAMPLE
        Get-CafWildcardPattern -Value 'virtualMachines'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]
        $Value
    )

    if ($Value -match '[*?\[\]]') {
        $Value
    }
    else {
        "*$Value*"
    }
}
function Resolve-CafResourceDefinition {
    <#
    .SYNOPSIS
        Resolves a -ResourceType, -Alias or -Resource value to exactly one resourceDefinition.json
        entry.
    .DESCRIPTION
        Shared resolution logic for Get-CafResourceName and Test-CafResourceName, so all three cmdlets
        identify a resource type the same way and report identical errors for an unknown/ambiguous
        value. Writes a non-terminating error and returns nothing if the value doesn't resolve to
        exactly one entry - callers should check for a $null/empty result and return without
        continuing.
 
        -ResourceType takes priority as the primary identifier if given, then -Alias, with -Resource
        narrowing *within* whichever of those matched (for the handful of types used by more than one
        kind of resource). When neither -ResourceType nor -Alias is given, -Resource is used as the
        primary identifier instead - every "Resource" friendly name in resourceDefinition.json happens
        to be globally unique today, so this only fails if that ever stops being true upstream.
 
        Callers don't need to guard against more than one of -ResourceType/-Alias/-Resource being
        supplied - e.g. piping Get-CafResourceType's output through by property name binds ResourceType,
        Resource and (via its Slug alias) Alias all at once, since they're all present on that object
        and all describe the same entry. Precedence just needs to be deterministic for that case, not
        an error - it's not a conflict unless the caller explicitly typed contradictory values, which
        isn't something this function can distinguish from the pipeline case anyway.
    .EXAMPLE
        Resolve-CafResourceDefinition -ResourceType 'Microsoft.Compute/virtualMachines'
    .EXAMPLE
        Resolve-CafResourceDefinition -Alias 'afd' -Resource 'classic'
    .EXAMPLE
        Resolve-CafResourceDefinition -Resource 'Key vault'
    #>

    [CmdletBinding()]
    param(
        [string]
        $ResourceType,

        [string]
        $Alias,

        [string]
        $Resource
    )

    $ResourceIsPrimary = -not $ResourceType -and -not $Alias

    if ($ResourceType) {
        $identifier = "'$ResourceType'"
        $candidates = Select-CafMatch -InputObject @(Get-CafResourceDefinition) -Property ResourceType -Value $ResourceType
    }
    elseif ($Alias) {
        $identifier = "alias '$Alias'"
        $candidates = Select-CafMatch -InputObject @(Get-CafResourceDefinition) -Property Slug -Value $Alias
    }
    else {
        $identifier = "resource '$Resource'"
        $candidates = Select-CafMatch -InputObject @(Get-CafResourceDefinition) -Property Resource -Value $Resource
    }

    if ($candidates.Count -eq 0) {
        Write-Error "No resource type matches $identifier. Use Get-CafResourceType to search for a supported resource type."
        return
    }

    if ($Resource -and -not $ResourceIsPrimary) {
        $allCandidates = $candidates
        $candidates = Select-CafMatch -InputObject $candidates -Property Resource -Value $Resource

        if ($candidates.Count -eq 0) {
            $options = ($allCandidates | ForEach-Object { "$($_.ResourceType) [$($_.Resource)]" }) -join "', '"
            Write-Error "'$Resource' doesn't match any resource for $identifier. Available: '$options'."
            return
        }
    }

    if ($candidates.Count -gt 1) {
        $options = ($candidates | ForEach-Object { "$($_.ResourceType) [$($_.Resource)]" }) -join "', '"
        $suggestion = if ($ResourceIsPrimary) { '-ResourceType' } else { '-Resource' }
        Write-Error "$identifier matches more than one resource type. Narrow it down with $suggestion`: '$options'."
        return
    }

    $candidates[0]
}
function Select-CafMatch {
    <#
    .SYNOPSIS
        Filters a collection to the items whose property exactly matches a value, falling back to a
        partial ('contains') match only when nothing matches exactly.
    .DESCRIPTION
        Used by Get-CafResourceName/Get-CafResourceType to make -ResourceType/-Resource match as a substring
        for convenience, without that breaking exact lookups: partial matching alone is ambiguous for
        values like 'Microsoft.Compute/virtualMachines', since (case-insensitively) it's also a
        substring of 'Microsoft.Compute/virtualMachineScaleSets'. Checking for an exact match first means
        a fully-qualified value always resolves to itself, and the substring fallback only kicks in when
        there was no exact match to begin with.
    .EXAMPLE
        Select-CafMatch -InputObject $definitions -Property ResourceType -Value 'Microsoft.Compute/virtualMachines'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]
        $InputObject,

        [Parameter(Mandatory)]
        [string]
        $Property,

        [Parameter(Mandatory)]
        [string]
        $Value
    )

    $exact = @($InputObject | Where-Object { $_.$Property -eq $Value })
    if ($exact.Count -gt 0) {
        return $exact
    }

    $pattern = Get-CafWildcardPattern -Value $Value
    @($InputObject | Where-Object { $_.$Property -like $pattern })
}