LibreDevOpsHelpers.MachineConfig/LibreDevOpsHelpers.MachineConfig.psm1

Set-StrictMode -Version Latest

function Install-LdoGuestConfigurationModule {
    <#
    .SYNOPSIS
        Installs the PowerShell modules needed to author and build machine configuration packages.

    .DESCRIPTION
        Installs the GuestConfiguration module (which produces the .zip package and, elsewhere, the
        policy JSON) plus the DSC resource modules used by the Libre DevOps hardening packages:
        PSDscResources (Registry and Script resources for Windows) and, on Linux, nxtools (nxFile,
        nxFileLine, nxScript, nxFileSystemObject for PSDSC v3). The correct PSDesiredStateConfiguration
        is installed per platform: 2.0.7 (stable) for Windows content, 3.0.0 (prerelease) for Linux
        content, matching the machine configuration authoring guidance. The GuestConfiguration module
        only builds on Ubuntu 18+, though the packages it produces run on any supported OS.

    .PARAMETER GuestConfigurationVersion
        GuestConfiguration module version to install. Defaults to the latest available.

    .PARAMETER IncludeLinuxResources
        Also install nxtools and the PSDesiredStateConfiguration 3.0.0 prerelease for building Linux
        (PSDSC v3) packages. Defaults to true on Linux, false on Windows.

    .EXAMPLE
        Install-LdoGuestConfigurationModule

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [string]$GuestConfigurationVersion,
        [Nullable[bool]]$IncludeLinuxResources
    )

    $onLinux = (Get-LdoOperatingSystem).ToLower() -eq 'linux'
    if ($null -eq $IncludeLinuxResources) { $IncludeLinuxResources = $onLinux }

    Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue

    $gcParams = @{ Name = 'GuestConfiguration'; Scope = 'CurrentUser'; Force = $true; AllowClobber = $true }
    if ($GuestConfigurationVersion) { $gcParams.RequiredVersion = $GuestConfigurationVersion }
    Write-LdoLog -Level INFO -Message 'Installing the GuestConfiguration module from PSGallery.'
    Install-Module @gcParams

    Write-LdoLog -Level INFO -Message 'Installing PSDscResources (Registry and Script resources).'
    Install-Module -Name 'PSDscResources' -Scope CurrentUser -Force -AllowClobber

    if ($IncludeLinuxResources) {
        Write-LdoLog -Level INFO -Message 'Installing nxtools and PSDesiredStateConfiguration 3.0.0 prerelease for Linux content.'
        Install-Module -Name 'nxtools' -Scope CurrentUser -Force -AllowClobber
        Install-Module -Name 'PSDesiredStateConfiguration' -RequiredVersion '3.0.0' -AllowPrerelease -Scope CurrentUser -Force -AllowClobber
    }
    else {
        Write-LdoLog -Level INFO -Message 'Installing PSDesiredStateConfiguration 2.0.7 for Windows content.'
        Install-Module -Name 'PSDesiredStateConfiguration' -RequiredVersion '2.0.7' -Scope CurrentUser -Force -AllowClobber
    }

    Write-LdoLog -Level SUCCESS -Message 'Machine configuration authoring modules installed.'
}

function New-LdoMachineConfigPackage {
    <#
    .SYNOPSIS
        Compiles a DSC configuration and builds a machine configuration package (.zip).

    .DESCRIPTION
        Dot sources the DSC configuration script (which must define a Configuration named -Name and
        invoke it on its last line), which compiles localhost.mof into a folder named after the
        configuration. The MOF is renamed to <Name>.mof and handed to New-GuestConfigurationPackage
        to produce the .zip. Use -Type AuditAndSet (the default) so an assignment can enforce with
        ApplyAndAutoCorrect; Audit builds an audit only package. Returns the full path to the .zip.

    .PARAMETER ConfigurationScript
        Path to the .ps1 that defines and invokes the Configuration.

    .PARAMETER Name
        The configuration and package name. Must match the Configuration keyword name in the script.

    .PARAMETER Type
        Audit or AuditAndSet. AuditAndSet is required for ApplyAndAutoCorrect enforcement.

    .PARAMETER OutputPath
        Working and output directory. Defaults to a fresh temp directory. The .zip lands here.

    .PARAMETER FrequencyMinutes
        Optional evaluation frequency baked into the package.

    .PARAMETER FilesToInclude
        Optional additional files to include in the package (third party content).

    .EXAMPLE
        New-LdoMachineConfigPackage -ConfigurationScript ./configurations/iis-hardening/IISHardening.ps1 -Name IISHardening -Type AuditAndSet

    .OUTPUTS
        System.String. The full path to the built .zip package.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ConfigurationScript,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Name,
        [ValidateSet('Audit', 'AuditAndSet')][string]$Type = 'AuditAndSet',
        [string]$OutputPath,
        [int]$FrequencyMinutes = 0,
        [string[]]$FilesToInclude = @()
    )

    if (-not (Test-Path $ConfigurationScript)) {
        throw "Configuration script not found: $ConfigurationScript"
    }
    if (-not (Get-Command 'New-GuestConfigurationPackage' -ErrorAction SilentlyContinue)) {
        throw 'The GuestConfiguration module is not available. Run Install-LdoGuestConfigurationModule first.'
    }

    $scriptFull = (Resolve-Path $ConfigurationScript).Path
    if (-not $OutputPath) {
        $OutputPath = Join-Path ([System.IO.Path]::GetTempPath()) ("ldo-mc-" + [guid]::NewGuid())
    }
    New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null

    Push-Location $OutputPath
    try {
        # Dot sourcing runs the script, which invokes the Configuration and compiles the MOF into
        # ./<Name>/localhost.mof relative to the current directory.
        Write-LdoLog -Level INFO -Message "Compiling DSC configuration '$Name' from $scriptFull"
        . $scriptFull

        $mofDir = Join-Path $OutputPath $Name
        $mof = Join-Path $mofDir 'localhost.mof'
        if (-not (Test-Path $mof)) {
            throw "Expected compiled MOF not found at $mof. Ensure the Configuration is named '$Name' and the script invokes it on its last line."
        }

        $namedMof = Join-Path $mofDir "$Name.mof"
        Rename-Item -Path $mof -NewName "$Name.mof" -Force

        $params = @{
            Name          = $Name
            Configuration = $namedMof
            Type          = $Type
            Path          = $OutputPath
            Force         = $true
        }
        if ($FrequencyMinutes -gt 0) { $params.FrequencyMinutes = $FrequencyMinutes }
        if (@($FilesToInclude).Count -gt 0) { $params.FilesToInclude = $FilesToInclude }

        Write-LdoLog -Level INFO -Message "Building machine configuration package '$Name' (Type $Type)."
        $pkg = New-GuestConfigurationPackage @params

        $zip = if ($pkg -and $pkg.Path) { $pkg.Path } else { Join-Path $OutputPath "$Name.zip" }
        if (-not (Test-Path $zip)) {
            throw "Package build did not produce a .zip at $zip."
        }
        Write-LdoLog -Level SUCCESS -Message "Built machine configuration package: $zip"
        return $zip
    }
    finally {
        Pop-Location
    }
}

function Test-LdoMachineConfigPackage {
    <#
    .SYNOPSIS
        Validates a built machine configuration package with Test-GuestConfigurationPackage.

    .DESCRIPTION
        Runs the GuestConfiguration module's package test against a built .zip and returns the
        result object. Throws when the module is not available.

    .PARAMETER ZipPath
        Path to the built .zip package.

    .PARAMETER Parameter
        Optional hashtable of configuration parameters to test with.

    .EXAMPLE
        Test-LdoMachineConfigPackage -ZipPath ./IISHardening.zip

    .OUTPUTS
        The Test-GuestConfigurationPackage result object.
    #>

    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ZipPath,
        [hashtable]$Parameter
    )

    if (-not (Test-Path $ZipPath)) { throw "Package not found: $ZipPath" }
    if (-not (Get-Command 'Test-GuestConfigurationPackage' -ErrorAction SilentlyContinue)) {
        throw 'The GuestConfiguration module is not available. Run Install-LdoGuestConfigurationModule first.'
    }

    $params = @{ Path = $ZipPath }
    if ($Parameter) { $params.Parameter = $Parameter }
    Write-LdoLog -Level INFO -Message "Validating machine configuration package: $ZipPath"
    Test-GuestConfigurationPackage @params
}

function Get-LdoMachineConfigPackageHash {
    <#
    .SYNOPSIS
        Returns the UPPERCASE SHA256 of a package .zip (the content_hash the module expects).

    .DESCRIPTION
        Get-FileHash already returns an uppercase hex digest, which is exactly the form the per
        machine assignment content_hash requires. Returned as a plain string.

    .PARAMETER ZipPath
        Path to the .zip package.

    .EXAMPLE
        Get-LdoMachineConfigPackageHash -ZipPath ./IISHardening.zip

    .OUTPUTS
        System.String. The uppercase SHA256 hex digest.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ZipPath
    )

    if (-not (Test-Path $ZipPath)) { throw "Package not found: $ZipPath" }
    (Get-FileHash -Algorithm SHA256 -Path $ZipPath).Hash
}

function Publish-LdoMachineConfigPackage {
    <#
    .SYNOPSIS
        Uploads a machine configuration package to a storage blob and returns its URI and hash.

    .DESCRIPTION
        Uploads the .zip to a storage account container with the Azure CLI (auth-mode login, so the
        signed in identity needs Storage Blob Data Contributor), then returns a hashtable with
        ContentUri and ContentHash ready to feed into machine_configuration_assignments. When
        -SasExpiryHours is supplied a user delegation SAS is appended to ContentUri so a machine can
        download the package without a data-plane role; otherwise the plain blob URL is returned
        (the machine's identity must have read access, or the container must allow anonymous read).

    .PARAMETER ZipPath
        Path to the built .zip package.

    .PARAMETER StorageAccountName
        Target storage account name.

    .PARAMETER ContainerName
        Target container. Must already exist.

    .PARAMETER BlobName
        Blob name. Defaults to the .zip file name.

    .PARAMETER SasExpiryHours
        When set, append a user delegation SAS valid for this many hours to the returned ContentUri.

    .EXAMPLE
        Publish-LdoMachineConfigPackage -ZipPath ./IISHardening.zip -StorageAccountName ldopkgs -ContainerName packages -SasExpiryHours 4

    .OUTPUTS
        System.Collections.Hashtable with keys ContentUri, ContentHash, BlobName.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ZipPath,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$StorageAccountName,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$ContainerName,
        [string]$BlobName,
        [int]$SasExpiryHours = 0
    )

    if (-not (Test-Path $ZipPath)) { throw "Package not found: $ZipPath" }
    Assert-LdoCommand -Name @('az')

    if (-not $BlobName) { $BlobName = Split-Path $ZipPath -Leaf }

    Write-LdoLog -Level INFO -Message "Uploading $BlobName to $StorageAccountName/$ContainerName"
    az storage blob upload `
        --account-name $StorageAccountName `
        --container-name $ContainerName `
        --name $BlobName `
        --file $ZipPath `
        --auth-mode login `
        --overwrite true `
        --only-show-errors | Out-Null
    Assert-LdoLastExitCode -Operation "upload $BlobName to $StorageAccountName/$ContainerName"

    $uri = (az storage blob url `
            --account-name $StorageAccountName `
            --container-name $ContainerName `
            --name $BlobName `
            --auth-mode login `
            --only-show-errors -o tsv).Trim()
    Assert-LdoLastExitCode -Operation "resolve blob url for $BlobName"

    if ($SasExpiryHours -gt 0) {
        # A user delegation SAS (az generates one with --auth-mode login --as-user) so no account
        # key is needed. The expiry is computed with the fixed helper timestamp form.
        $expiry = (Get-Date).ToUniversalTime().AddHours($SasExpiryHours).ToString('yyyy-MM-ddTHH:mmZ')
        $sas = (az storage blob generate-sas `
                --account-name $StorageAccountName `
                --container-name $ContainerName `
                --name $BlobName `
                --permissions r `
                --expiry $expiry `
                --auth-mode login `
                --as-user `
                --full-uri `
                --only-show-errors -o tsv).Trim()
        Assert-LdoLastExitCode -Operation "generate SAS for $BlobName"
        $uri = $sas
    }

    $hash = Get-LdoMachineConfigPackageHash -ZipPath $ZipPath
    Write-LdoLog -Level SUCCESS -Message "Published $BlobName (hash $hash)."

    @{ ContentUri = $uri; ContentHash = $hash; BlobName = $BlobName }
}

Export-ModuleMember -Function `
    Install-LdoGuestConfigurationModule, `
    New-LdoMachineConfigPackage, `
    Test-LdoMachineConfigPackage, `
    Get-LdoMachineConfigPackageHash, `
    Publish-LdoMachineConfigPackage