runbook/runbook.psm1

using module ../utils/utils.psd1

<#
.FUNCTION
    Get-RunbookSchema
 
.SYNOPSIS
    Retrieves the JSON schema for a runbook.
 
.DESCRIPTION
    The `Get-RunbookSchema` function returns the JSON schema that defines the structure
    of a Well-Architected Reliability Assessment (WARA) runbook. This schema ensures consistency
    in the configuration and validation of runbook content.
 
.OUTPUTS
    [string]
    The JSON schema as a string.
 
.EXAMPLE
    $schema = Get-RunbookSchema
 
    Retrieves the JSON schema for a runbook, ensuring it adheres to the expected structure.
 
.NOTES
    Author: Casey Watson
    Date: 2025-02-27
#>

function Get-RunbookSchema {
    @"
{
  "title": "Runbook",
  "description": "A well-architected reliability assessment (WARA) runbook",
  "type": "object",
  "properties": {
    "parameters": {
      "type": "object"
    },
    "variables": {
      "type": "object"
    },
    "selectors": {
      "type": "object",
      "additionalProperties": {
        "type": "string"
      }
    },
    "checks": {
      "type": "object",
      "additionalProperties": {
        "type": "object",
        "additionalProperties": {
          "oneOf": [
            {
              "type": "string"
            },
            {
              "type": "object",
              "properties": {
                "selector": {
                  "type": "string"
                },
                "parameters": {
                  "type": "object"
                },
                "tags": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              },
              "required": [
                "selector"
              ]
            }
          ]
        }
      }
    }
  },
  "required": [
    "selectors",
    "checks"
  ]
}
"@

}

<#
.FUNCTION
    New-RunbookFactory
 
.SYNOPSIS
    Instantiates a new RunbookFactory object.
 
.DESCRIPTION
    The `New-RunbookFactory` function creates an instance of the `RunbookFactory` class,
    which is responsible for parsing runbook files and generating `Runbook` instances.
 
.OUTPUTS
    [RunbookFactory]
    A new instance of the RunbookFactory class.
 
.EXAMPLE
    $factory = New-RunbookFactory
 
    Creates a new instance of the RunbookFactory class, which can then be used
    to parse runbook content.
 
.NOTES
    Author: Casey Watson
    Date: 2025-02-27
#>

function New-RunbookFactory {
    return [RunbookFactory]::new()
}

<#
.FUNCTION
    New-Runbook
 
.SYNOPSIS
    Creates a new Runbook instance.
 
.DESCRIPTION
    The `New-Runbook` function returns a new instance of the `Runbook` class.
    It can optionally be initialized from a JSON string (-FromJson) or a JSON file (-FromJsonFile),
    but not both.
 
.PARAMETER FromJson
    JSON content to initialize the runbook. Cannot be used with -FromJsonFile.
 
.PARAMETER FromJsonFile
    Path to a JSON file to initialize the runbook. Cannot be used with -FromJson.
 
.OUTPUTS
    [Runbook]
    A new `Runbook` instance.
 
.EXAMPLE
    $runbook = New-Runbook
 
    Creates an empty `Runbook` instance.
 
.EXAMPLE
    $runbook = New-Runbook -FromJson $jsonContent
 
    Initializes a `Runbook` instance from JSON content.
 
.EXAMPLE
    $runbook = New-Runbook -FromJsonFile "C:\runbook.json"
 
    Initializes a `Runbook` instance from a JSON file.
 
.NOTES
    - If neither `-FromJson` nor `-FromJsonFile` is specified, an empty `Runbook` instance is returned.
    - If both `-FromJson` and `-FromJsonFile` are specified, the function throws an error.
    - The provided JSON must be valid and match the expected `Runbook` schema.
 
    Author: Casey Watson
    Date: 2025-02-27
#>

function New-Runbook {
    param(
        [Parameter(Mandatory = $false)]
        [string] $FromJson,

        [Parameter(Mandatory = $false)]
        [ValidateScript({ Test-FileExists -Path $_ })]
        [string] $FromJsonFile
    )

    if ($FromJson -or $FromJsonFile) {
        if ($FromJson -and $FromJsonFile) {
            throw "Cannot specify both -FromJson and -FromJsonFile."
        }
        else {
            $runbookFactory = New-RunbookFactory

            if ($FromJson) {
                return $runbookFactory.ParseRunbookContent($FromJson)
            }
            elseif ($(Test-RunbookFile -Path $FromJsonFile)) {
                return $runbookFactory.ParseRunbookFile($FromJsonFile)
            }
        }
    }
    else {
        return [Runbook]::new()
    }
}

<#
.FUNCTION
    New-Recommendation
 
.SYNOPSIS
    Creates a new `Recommendation` instance.
 
.DESCRIPTION
    The `New-Recommendation` function initializes and returns a new instance of the `Recommendation` class.
    This object contains metadata and evaluation logic for a specific runbook check.
 
.OUTPUTS
    [Recommendation]
    A new `Recommendation` object.
 
.EXAMPLE
    $recommendation = New-Recommendation
 
    Creates a new `Recommendation` instance.
 
.NOTES
    Author: Casey Watson
    Date: 2025-02-27
#>

function New-Recommendation {
    return [Recommendation]::new()
}

<#
.FUNCTION
    New-RunbookRecommendation
 
.SYNOPSIS
    Creates a new `RunbookRecommendation` instance.
 
.DESCRIPTION
    The `New-RunbookRecommendation` function initializes and returns a new instance of
    the `RunbookRecommendation` class, which encapsulates metadata for a specific runbook
    check, including its associated recommendation.
 
.OUTPUTS
    [RunbookRecommendation]
    A new `RunbookRecommendation` object.
 
.EXAMPLE
    $runbookRecommendation = New-RunbookRecommendation
 
    Creates a new `RunbookRecommendation` instance.
 
.NOTES
    Author: Casey Watson
    Date: 2025-02-27
#>

function New-RunbookRecommendation {
    return [RunbookRecommendation]::new()
}

<#
.FUNCTION
    New-RunbookCheckSet
 
.SYNOPSIS
    Creates a new `RunbookCheckSet` instance.
 
.DESCRIPTION
    The `New-RunbookCheckSet` function returns a new instance of the `RunbookCheckSet` class,
    which represents a logical grouping of related runbook checks.
 
.OUTPUTS
    [RunbookCheckSet]
    A new `RunbookCheckSet` instance.
 
.EXAMPLE
    $checkSet = New-RunbookCheckSet
 
    Creates a new `RunbookCheckSet` instance.
 
.NOTES
    Author: Casey Watson
    Date: 2025-02-27
#>

function New-RunbookCheckSet {
    return [RunbookCheckSet]::new()
}

<#
.FUNCTION
    New-RunbookCheck
 
.SYNOPSIS
    Creates a new `RunbookCheck` instance.
 
.DESCRIPTION
    The `New-RunbookCheck` function returns a new instance of the `RunbookCheck` class,
    representing an individual check within a runbook. Each check is associated with a selector
    and contains parameterized logic for evaluating resource compliance.
 
.OUTPUTS
    [RunbookCheck]
    A new `RunbookCheck` instance.
 
.EXAMPLE
    $check = New-RunbookCheck
 
    Creates a new `RunbookCheck` instance.
 
.NOTES
    Author: Casey Watson
    Date: 2025-02-27
#>

function New-RunbookCheck {
    return [RunbookCheck]::new()
}

<#
.FUNCTION
    Build-RunbookQueries
 
.SYNOPSIS
    Constructs queries for runbook checks.
 
.DESCRIPTION
    The `Build-RunbookQueries` function generates a list of queries based on the check sets
    and associated recommendations within a runbook. It dynamically merges parameters, variables,
    and selectors to construct accurate queries for evaluation.
 
.PARAMETER Runbook
    The `Runbook` object containing check sets, parameters, and selectors.
 
.PARAMETER Recommendations
    An array of `RunbookRecommendation` objects defining the checks to be executed.
 
.PARAMETER ProgressId
    (Optional) A progress indicator ID for `Write-Progress`.
 
.OUTPUTS
    [RunbookQuery[]]
    An array of `RunbookQuery` objects, each containing a check name, query, tags, and recommendation.
 
.EXAMPLE
    $queries = Build-RunbookQueries -Runbook $runbook -Recommendations $recommendations
 
    Constructs queries for the given runbook and recommendations.
 
.NOTES
    - Queries are created by combining parameters, variables, and selectors with recommendation queries.
    - Throws an error if a check references a missing selector.
    - Ensures that only recommendations matching the runbook's check sets are processed.
 
    Author: Casey Watson
    Date: 2025-02-27
#>

function Build-RunbookQueries {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [Runbook] $Runbook,

        [Parameter(Mandatory = $true)]
        [RunbookRecommendation[]] $Recommendations,

        [Parameter(Mandatory = $false)]
        [int] $ProgressId = 30
    )

    $checkCount = 0
    $checkIndex = 0

    foreach ($checkSetKey in $Runbook.CheckSets.Keys) {
        $checkSet = $Runbook.CheckSets[$checkSetKey]
        $checkCount += $checkSet.Checks.Count
    }

    $queries = @()
    $globalParameters = @{}

    if ($Runbook.Parameters) {
        foreach ($globalParameterKey in $Runbook.Parameters.Keys) {
            $globalParameters[$globalParameterKey] = $Runbook.Parameters[$globalParameterKey].ToString()
        }
    }

    if ($Runbook.Variables) {
        foreach ($variableKey in $Runbook.Variables.Keys) {
            $variableValue = $Runbook.Variables[$variableKey].ToString()
            $globalParameters[$variableKey] = Merge-ParametersIntoString -Parameters $globalParameters -Into $variableValue
        }
    }

    $recommendationsMap = @{}

    foreach ($recommendation in $Recommendations) {
        if (-not ($recommendationsMap.ContainsKey($recommendation.CheckSetName))) {
            $recommendationsMap[$recommendation.CheckSetName] = @{}
        }

        $recommendationsMap[$recommendation.CheckSetName][$recommendation.CheckName] = $recommendation.Recommendation
    }

    foreach ($checkSetKey in $Runbook.CheckSets.Keys) {
        if (-not ($recommendationsMap.ContainsKey($checkSetKey))) {
            throw "No recommendations found for check set [$checkSetKey]."
        }

        $checkSet = $Runbook.CheckSets[$checkSetKey]

        foreach ($checkKey in $checkSet.Checks.Keys) {
            $checkIndex++
            $pctComplete = (($checkIndex / $checkCount) * 100)

            Write-Progress `
                -Activity "Building runbook queries" `
                -Status "$checkKey" `
                -PercentComplete $pctComplete `
                -Id $ProgressId

            if (-not ($recommendationsMap[$checkSetKey].ContainsKey($checkKey))) {
                throw "No recommendation found for check [$checkSetKey]:[$checkKey]."
            }

            $check = $checkSet.Checks[$checkKey]
            $recommendation = $recommendationsMap[$checkSetKey][$checkKey]

            $checkParameters = @{}
            $checkParameters += $globalParameters

            foreach ($checkParameterKey in $check.Parameters.Keys) {
                $checkParameterValue = $check.Parameters[$checkParameterKey].ToString()
                $checkParameters[$checkParameterKey] = Merge-ParametersIntoString -Parameters $globalParameters -Into $checkParameterValue
            }

            if ($Runbook.Selectors.ContainsKey($check.SelectorName)) {
                $query = Merge-ParametersIntoString -Parameters $checkParameters -Into $recommendation.Query

                foreach ($selectorKey in $Runbook.Selectors.Keys) {
                    $selector = $Runbook.Selectors[$selectorKey]
                    $selector = Merge-ParametersIntoString -Parameters $checkParameters -Into $selector
                    $query = $($query -replace "//\s*selector:$($selectorKey)", "| where $selector")
                }

                $selector = Merge-ParametersIntoString -Parameters $checkParameters -Into $Runbook.Selectors[$check.SelectorName]
                $query = $($query -replace "//\s*selector", "| where $selector")

                $queries += [RunbookQuery]@{
                    CheckSetName   = $checkSetKey
                    CheckName      = $checkKey
                    SelectorName   = $check.SelectorName
                    Query          = $query
                    Tags           = $check.Tags
                    Recommendation = $recommendation
                }
            }
        }
    }

    Write-Progress -Id $ProgressId -Completed

    return $queries
}

<#
.FUNCTION
    Merge-ParametersIntoString
 
.SYNOPSIS
    Replaces placeholders in a string with parameter values.
 
.DESCRIPTION
    The `Merge-ParametersIntoString` function iterates through a hashtable of parameters
    and replaces placeholders in the input string with corresponding values.
    Placeholders must follow the `{{Key}}` format.
 
.PARAMETER Parameters
    A hashtable containing key-value pairs for replacement.
 
.PARAMETER Into
    The string containing placeholders to be replaced.
 
.OUTPUTS
    [string]
    A string with placeholders replaced by their corresponding values.
 
.EXAMPLE
    $params = @{ "Region" = "eastus"; "Env" = "Production" }
    $result = Merge-ParametersIntoString -Parameters $params -Into "Deploying to {{Region}} in {{Env}}."
 
    Returns: "Deploying to eastus in Production."
 
.NOTES
    - Only placeholders matching keys in the hashtable are replaced.
    - Uses simple string replacement logic.
 
    Author: Casey Watson
    Date: 2025-02-27
#>

function Merge-ParametersIntoString {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [hashtable] $Parameters,

        [Parameter(Mandatory = $true)]
        [string] $Into
    )

    foreach ($parameterKey in $Parameters.Keys) {
        $Into = $Into.Replace("{{$parameterKey}}", $Parameters[$parameterKey])
    }

    return $Into
}

<#
.FUNCTION
    Read-RunbookFile
 
.SYNOPSIS
    Reads and parses a runbook file.
 
.DESCRIPTION
    The `Read-RunbookFile` function validates and loads a runbook from a JSON file.
    If the file is valid, it returns a parsed `Runbook` instance.
 
.PARAMETER Path
    The file path to the runbook JSON file.
 
.OUTPUTS
    [Runbook]
    A parsed `Runbook` object.
 
.EXAMPLE
    $runbook = Read-RunbookFile -Path "C:\runbook.json"
 
    Reads and parses the specified runbook file.
 
.NOTES
    - Uses `Test-RunbookFile` to validate the file before parsing.
    - If validation fails, an error is thrown.
    - The runbook is parsed using `RunbookFactory`.
 
    Author: Casey Watson
    Date: 2025-02-27
#>

function Read-RunbookFile {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateScript({ Test-FileExists $_ })]
        [string] $Path
    )

    if (Test-RunbookFile -Path $Path) {
        $runbookFactory = New-RunbookFactory
        return $runbookFactory.ParseRunbookFile($Path)
    }
}

<#
.FUNCTION
    Write-RunbookFile
 
.SYNOPSIS
    Saves a `Runbook` object to a JSON file.
 
.DESCRIPTION
    The `Write-RunbookFile` function validates the provided `Runbook` object
    and serializes it into a JSON file at the specified path.
 
.PARAMETER Runbook
    The `Runbook` object to be saved.
 
.PARAMETER Path
    The file path where the `Runbook` JSON should be written.
 
.OUTPUTS
    None
 
.EXAMPLE
    Write-RunbookFile -Runbook $myRunbook -Path "C:\runbook.json"
 
    Saves the provided `Runbook` object to "C:\runbook.json".
 
.NOTES
    - Ensures the `Runbook` is valid before saving.
    - The output file is formatted in JSON.
 
    Author: Casey Watson
    Date: 2025-02-27
#>

function Write-RunbookFile {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [Runbook] $Runbook,

        [Parameter(Mandatory = $true)]
        [string] $Path
    )

    $Runbook.Validate()

    $runbookFileContents = @{
        query_paths = $Runbook.QueryPaths
        parameters  = $Runbook.Parameters
        variables   = $Runbook.Variables
        selectors   = $Runbook.Selectors
        checks      = @{}
    }

    foreach ($checkSetKey in $Runbook.CheckSets.Keys) {
        $checkSet = $Runbook.CheckSets[$checkSetKey]
        $checkSetContents = @{}

        foreach ($checkKey in $checkSet.Checks.Keys) {
            $check = $checkSet.Checks[$checkKey]

            $checkContents = @{
                parameters = ($check.Parameters ?? @{})
                selector   = $check.SelectorName
                tags       = ($check.Tags ?? @())
            }

            $checkSetContents[$checkKey] = $checkContents
        }

        $runbookFileContents.checks[$checkSetKey] = $checkSetContents
    }

    $runbookFileJson = $runbookFileContents | ConvertTo-Json -Depth 15
    $runbookFileJson | Out-File -FilePath $Path -Force
}

<#
.FUNCTION
    Test-RunbookFile
 
.SYNOPSIS
    Validates a runbook file.
 
.DESCRIPTION
    The `Test-RunbookFile` function checks whether a specified runbook JSON file is
    valid according to the runbook schema. It ensures the JSON structure is correct
    and adheres to expected schema requirements.
 
.PARAMETER Path
    The full file path to the runbook JSON file.
 
.OUTPUTS
    [bool]
    Returns `$true` if the file is valid; otherwise, an error is thrown.
 
.EXAMPLE
    $isValid = Test-RunbookFile -Path "C:\runbook.json"
 
    Returns `$true` if the runbook is valid.
 
.NOTES
    - Uses `Test-Json` to validate JSON structure.
    - If validation fails, an error is thrown.
 
    Author: Casey Watson
    Date: 2025-02-27
#>

function Test-RunbookFile {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateScript({ Test-FileExists $_ })]
        [string] $Path
    )

    $fileContent = Get-Content -Path $Path -Raw

    if (-not ($fileContent | Test-Json -ErrorAction SilentlyContinue)) {
        throw "[$Path] is not a valid JSON file."
    }

    if (-not ($fileContent | Test-Json -ErrorAction SilentlyContinue -Schema $(Get-RunbookSchema))) {
        throw "[$Path] does not adhere to the runbook JSON schema. Run [Get-RunbookSchema] to get the schema."
    }

    $runbookFactory = New-RunbookFactory
    $runbookFactory.ParseRunbookContent($fileContent).Validate()

    return $true
}

<#
.FUNCTION
    Build-RunbookSelectorReview
 
.SYNOPSIS
    Builds a selector review for a runbook.
 
.DESCRIPTION
    The `Build-RunbookSelectorReview` function evaluates each selector in a runbook,
    resolves parameters, and executes queries to identify matching resources across
    specified subscriptions.
 
.PARAMETER Runbook
    The `Runbook` object containing selectors, parameters, and variables.
 
.PARAMETER SubscriptionIds
    (Optional) An array of subscription IDs to scope the queries.
 
.OUTPUTS
    [SelectorReview]
    A `SelectorReview` object mapping each selector to its resolved query and matched resources.
 
.EXAMPLE
    $review = Build-RunbookSelectorReview -Runbook $runbook -SubscriptionIds @("sub1", "sub2")
 
    Generates a selector review to verify correct resource scoping.
 
.NOTES
    - Selectors define which resources are included in a runbook.
    - Misconfigured selectors may cause missing or incorrect results.
    - Uses `Invoke-WAFQuery` to fetch matching resources.
 
    Author: Casey Watson
    Date: 2025-02-27
#>

function Build-RunbookSelectorReview {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [Runbook] $Runbook,

        [Parameter(Mandatory = $false)]
        [AllowEmptyCollection()]
        [string[]] $SubscriptionIds
    )

    $selectorReview = [SelectorReview]::new()

    $globalParameters = @{}

    if ($Runbook.Parameters) {
        foreach ($globalParameterKey in $Runbook.Parameters.Keys) {
            $globalParameters[$globalParameterKey] = $Runbook.Parameters[$globalParameterKey].ToString()
        }
    }

    if ($Runbook.Variables) {
        foreach ($variableKey in $Runbook.Variables.Keys) {
            $variableValue = $Runbook.Variables[$variableKey].ToString()
            $globalParameters[$variableKey] = Merge-ParametersIntoString -Parameters $globalParameters -Into $variableValue
        }
    }

    for ($i = 0; $i -lt $Runbook.Selectors.Keys.Count; $i++) {
        $selectorKey = $Runbook.Selectors.Keys[$i]
        $selector = Merge-ParametersIntoString -Parameters $globalParameters -Into $Runbook.Selectors[$selectorKey]
        $pctComplete = ((($i + 1) / $Runbook.Selectors.Keys.Count) * 100)

        Write-Progress `
            -Activity "Building selector review..." `
            -Status "$pctComplete% - Processing selector [$selectorKey]" `
            -PercentComplete $pctComplete

        $selectedResourceSet = [SelectedResourceSet]@{
            Selector           = $selector
            ResourceGraphQuery = Build-SelectorResourceGraphQuery -Selector $selector
        }

        $selectedResources = Invoke-WAFQuery `
            -Query $selectedResourceSet.SelectorResourceGraphQuery `
            -SubscriptionIds $SubscriptionIds

        foreach ($selectedResource in $selectedResources) {
            $selectedResourceSet.Resources += [SelectedResource]@{
                ResourceId        = $selectedResource.id
                ResourceType      = $selectedResource.type
                ResourceName      = $selectedResource.name
                ResourceLocation  = $selectedResource.location
                ResourceGroupName = $selectedResource.resourceGroup
                ResourceTags      = $(ConvertTo-Json $selectedResource.tags | ConvertFrom-Json -AsHashtable)
            }
        }

        $selectorReview.Selectors[$selectorKey] = $selectedResourceSet
    }

    Write-Progress -Activity "Selector review built." -Completed

    return $selectorReview
}

<#
.FUNCTION
    Build-SelectorResourceGraphQuery
 
.SYNOPSIS
    Constructs an Azure Resource Graph query from a selector.
 
.DESCRIPTION
    The `Build-SelectorResourceGraphQuery` function creates a Resource Graph query
    that filters resources based on the provided selector expression.
 
.PARAMETER Selector
    The filter expression used to scope resources in the query.
 
.OUTPUTS
    [string]
    The formatted Azure Resource Graph query.
 
.EXAMPLE
    $query = Build-SelectorResourceGraphQuery -Selector "type == 'Microsoft.Compute/virtualMachines'"
 
    Generates a query to filter virtual machines.
 
.NOTES
    - The selector should be a valid KQL (Kusto Query Language) expression.
    - The output query can be executed using Azure Resource Graph API.
 
    Author: Casey Watson
    Date: 2025-02-27
#>

function Build-SelectorResourceGraphQuery {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $Selector
    )

    return @"
resources
| where $Selector
| project id, type, location, name, resourceGroup, tags
"@

}