Start-OsDeploy.ps1

Function Start-OsDeploy {
    Param(
        [alias('env')]
        # [Parameter(Mandatory = $true)]
        # [validatepattern('\A\w\d\Z')]
        [string]$Environment,
        [alias('Dir', 'Path')]
        [string] $ProjectDirectory = (Get-Location | Select-Object -ExpandProperty Path).ToString(),
        [alias('TF')]
        [string] $TemplateFile,
        [string] $TemplateParametersFile,
        [alias('P')]
        [string] $Project,
        [string] $DeploymentName,
        [string] $StorageAccountName,
        [string] $ResourceGroupLocation = 'eastus2',
        [switch] $ValidateOnly,
        [validateset('All', 'None', 'RequestContent', 'ResponseContent')]
        [string] $DebugOptions = "None",
        [switch] $subscriptiondeploy,
        [switch] $NoUpload,
        [switch] $FullUpload,
        [alias('m')]
        [array] $Modules,
        [string] $prefix,
        [alias('rg')]
        [string] $ResoureGroup,
        [alias('odir', 'o')]
        [array] $AddOsirisFilePath,
        [switch] $WhatIf
    )

    $ErrorActionPreference = 'Stop'
    # Set-StrictMode -Version 3
    
    if (-not $project) {
        try {
            $envJson = Get-Content "$ProjectDirectory\global.parameters.json" -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
            $project = $envJson.parameters.Global.value.AppName
        }
        catch {
            throw "No project name found! No input parameter or project global.parameters.json AppName found."
        }
    }

    if (-not $prefix) {
        $prefix = Switch -Regex ($ResourceGroupLocation) {
            'eastus2' { 'SPZE2' }
            'centralus' { 'SPZC1' }
            default { throw "Location $ResourceGroupLocation is either not supported or not found!" }
        }
    }

    if ($ResoureGroup) {
        $ResourceGroupName = $ResoureGroup
        $Environment=$ResoureGroup
    }
    else {
        $ResourceGroupName = ($prefix + '-' + $project + '-' + $Environment).toUpper()
    }

    Write-Output "Resource Group: $ResourceGroupName" -ForegroundColor Green

    # if ($ResourceGroupName -notmatch "^[A-Z][0-9]+-[A-Z][0-9]+-[A-Z][0-9]+$") {
    # throw "Resource Group name is not formatted correctly: $ResourceGroupName"
    # }

    # Run template and parameter json creation functions of Osiris.
    if (-not $TemplateParametersFile) {
        New-OsARMParameterTemplate -ProjectDirectory $ProjectDirectory -ResourceGroupLocation $ResourceGroupLocation -Environment $Environment -Modules $Modules
        $TemplateParametersFile = "$ProjectDirectory\temp\latest-template-deployment.json" 
    }
    if (-not $TemplateFile) {
        New-OsARMDeploymentTemplate -ProjectDirectory $ProjectDirectory -ResourceGroupLocation $ResourceGroupLocation -Environment $Environment -Modules $Modules
        $TemplateFile = "$ProjectDirectory\temp\latest-module-deploy.json"
    }

    if ($WhatIf) {
        Write-Host "Template files generated successfully." -ForegroundColor Green
        break
    }

    # Check if resource group already exists, if not creates it.
    if (-not $subscriptiondeploy) {
        try {
            Get-AzResourceGroup -Name $ResourceGroupName -Location $ResourceGroupLocation -Verbose -ErrorAction Stop | Out-Null
        }
        catch {
            Write-Warning "No resource group found! Creating resource group: $ResourceGroupName"
            New-AzResourceGroup -Name $ResourceGroupName -Location $ResourceGroupLocation -Verbose -ErrorAction Stop
        }
    }

    # If no storage account is provided as a parameter then either use the default one for the environment or create one for the environment if not.
    $StorageContainerName = ($ResourceGroupName + "-staging-$env:USERNAME").ToLower()
    if (-not $StorageAccountName) {
        #This ugly regex replace trims the $StorageAccountName to 24 characters or less as required by Azure storage accounts.
        $StorageAccountName = ($ResourceGroupName + 'staging').ToLower().Replace("-", "") -Replace '(?<=^.{24}).*'
        Write-Verbose "Storage Account: $StorageAccountName"
        Write-Verbose "Storage Container: $StorageContainerName"
        $StorageAccount = Get-AzStorageAccount | Where-Object { $_.StorageAccountName -eq $StorageAccountName }
        if (-not $StorageAccount) {
            Write-Warning "No storage account found! Creating a storage account and running a full initial upload of modules to the container. This can take a while!"
            New-AzStorageAccount -StorageAccountName $StorageAccountName -Type 'Standard_LRS' -ResourceGroupName $ResourceGroupName -Location $ResourceGroupLocation -ErrorAction Stop
            $StorageAccount = Get-AzStorageAccount | Where-Object { $_.StorageAccountName -eq $StorageAccountName } -ErrorAction Stop
            $FullUpload = $true
        }
    }
    else {
        Write-Verbose "Storage Account: $StorageAccountName"
        Write-Verbose "Storage Container: $StorageContainerName"
        $StorageAccount = Get-AzStorageAccount | Where-Object { $_.StorageAccountName -eq $StorageAccountName }
        $FullUpload = $true
    }

    if (-not $DeploymentName) {
        $DeploymentName = (($ResourceGroupName) + '-' + $env:UserName.ToUpper() + '-' + ((Get-Date)).ToString('yyyy-MM-dd-HHmm'))
    }
    
    # Setting up ARM template deployment parameters to pass in with PowerShell.
    $TemplateParameters = @{ }

    $TemplateParameters['EnvName'] = $ResourceGroupName
    $TemplateParameters.Add('TemplateFile', $TemplateFile)
    $TemplateParameters.Add('TemplateParameterFile', $TemplateParametersFile)

    Write-Host "Template File: $TemplateFile" -ForegroundColor Green
    Write-Host "Parameters File: $TemplateParametersFile" -ForegroundColor Green

    # Generate a 4 hour SAS token for the artifacts location.
    $TemplateParameters['_artifactsLocationSasToken'] = New-AzStorageContainerSASToken -Container $StorageContainerName -Permission r -Context $StorageAccount.Context -ExpiryTime (Get-Date).AddHours(4)
    $TemplateParameters['_artifactsLocationSasToken'] = ConvertTo-SecureString $TemplateParameters['_artifactsLocationSasToken'] -AsPlainText -Force
    $TemplateParameters['_artifactsLocation'] = $StorageAccount.Context.BlobEndPoint + $StorageContainerName

    if (-not $ValidateOnly) {
        $TemplateParameters.Add('DeploymentDebugLogLevel', $DebugOptions)
    }

    # Deployment script provided template parameters to define a specific project and environment.
    $TemplateFileJson = Get-Content $TemplateFile -Raw | ConvertFrom-Json
    $TemplateParamJson = Get-Content $TemplateParametersFile -Raw | ConvertFrom-Json
    $TemplateFileJsonList = $TemplateFileJson.parameters | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name
    $TemplateParamJsonList = $TemplateParamJson.parameters | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name

    $FinalParamList = $TemplateFileJsonList | Where-Object { $TemplateParamJsonList -notcontains $_ }

    Switch -Regex ($FinalParamList) {
        'Prefix' { $TemplateParameters['Prefix'] = $Prefix }
        'Environment' { $TemplateParameters['Environment'] = $Environment.substring(0, 1) }
        'DeploymentID' { $TemplateParameters['DeploymentID'] = $Environment.substring(1, 1) }
    }
    
    Write-Verbose "All Params: $FinalParamList"
 
    # Remove parameters for modules that are empty to get rid of clutter.
    # ForEach ($param in $TemplateParamJsonList) {
    # if ($TemplateParamJson.parameters.$param.value.GetType().Name -eq "PSCustomObject") {
    # if (($TemplateParamJson.parameters.$param.value | Get-Member -MemberType NoteProperty).Count -eq 0) {
    # $TemplateParamJson.parameters = $TemplateParamJson.parameters | Select-Object -Exclude $param
    # $TemplateFileJson.parameters = $TemplateFileJson.parameters | Select-Object -Exclude $param
    # }
    # }
    # }
   
    $TemplateFileJson | ConvertTo-Json -Depth 100 | Out-File $TemplateFile
    $TemplateParamJson | ConvertTo-Json -Depth 100 | Out-File $TemplateParametersFile

    # Convert relative paths to absolute paths if needed.
    $ProjectDirectory = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($PSScriptRoot, $ProjectDirectory))

    # Try to upload only the bare minimum files needed for the deployment.
    if (-not $NoUpload) {
        $EnvHashesFileName = "$project-$Environment-$ResourceGroupLocation-$env:UserName"

        if (-not (Test-Path -Path "$ProjectDirectory\temp")) {
            New-Item -Path "$ProjectDirectory\temp" -ItemType Directory | Out-Null
        }
        
        if (-not (Test-Path -Path "$ProjectDirectory\temp\filehashes")) {
            New-Item -Path "$ProjectDirectory\temp\filehashes" -ItemType Directory | Out-Null
        }
        
        # It is better to use a try catch block since this is a single API call to Azure.
        # Compare this to an if statement with a Get storage API call to first check if storage account exists then an Add API call to add the container.
        try {
            New-AzStorageContainer -Name $StorageContainerName -Context $StorageAccount.Context -ErrorAction Stop
            Write-Warning "Created new storage container $StorageContainerName"
        }
        catch {
            Write-Verbose "Using existing storage container $StorageContainerName"
        }

        # Setup metadata JSON object to verify MD5 file hashes from the last upload depending on if there is an existing file or not.
        try {
            $EnvMetadataJson = Get-Content "$ProjectDirectory\temp\filehashes\$EnvHashesFileName.json" -Raw  -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
        }
        catch {
            Write-Verbose "No existing file hashes cache found!"
            Write-Verbose "Creating new hashes cache file at: $ProjectDirectory\temp\filehashes\$EnvHashesFileName.json"
            Write-Verbose "Running a full module upload to build initial file hash cache."
            $EnvMetadataJson = New-Object -TypeName PSCustomObject
            $EnvMetadataJson | Add-Member -MemberType NoteProperty -Name "filehashes" -Value (New-Object -TypeName PSCustomObject)
            $FullUpload = $true
        }

        if ($FullUpload) {
            Get-ChildItem "$ProjectDirectory\modules" | ForEach-Object {
                $ModName = $_.Name
                Get-ChildItem -File -Recurse $_.FullName | Where-Object { $_.Name -notmatch 'readme|example|metadata' } | ForEach-Object {
                    Write-Output "Uploading: $_"
                    Set-AzStorageBlobContent -Container $StorageContainerName -Context $StorageAccount.Context -File $_.FullName -Blob "modules/$ModName/$($_.Name)" -Force | Out-Null
                    $ModFileHash = Get-FileHash -Algorithm MD5 -Path $_.FullName
                    $EnvMetadataJson.filehashes | Add-Member -MemberType NoteProperty -Name $_.FullName -Value $ModFileHash.Hash -Force
                }
            }
        }
        else {
            $script:AllModulesUsedList | ForEach-Object {
                $ModName = $_
                Write-Verbose "ModName: $ModName"
                Get-ChildItem -File -Recurse "$ProjectDirectory\modules" | Where-Object { $_.Directory -match $ModName -and $_.Name -notmatch 'readme|example' } | ForEach-Object {
                    $ModFilePath = $_

                    # Get the MD5 hashes of all files to be uploaded to check if they need to be uploaded to the storage container.
                    $ModFileHash = Get-FileHash -Algorithm MD5 -Path $ModFilePath.FullName
                    if ($EnvMetadataJson.filehashes | Get-Member -MemberType NoteProperty | Where-Object { $_.Name -eq $ModFilePath.FullName } -ErrorAction SilentlyContinue) {
                        $ModFileHashUpdated = $EnvMetadataJson.filehashes.($ModFilePath.FullName) -eq $ModFileHash.Hash
                    }
                    else {
                        $ModFileHashUpdated = $false
                    }
                    if (-not $ModFileHashUpdated) {
                        Write-Output "Uploading: $ModFilePath"
                        Set-AzStorageBlobContent -Container $StorageContainerName -Context $StorageAccount.Context -File $_.FullName -Blob "modules/$ModName/$($_.Name)" -Force | Out-Null
                        $EnvMetadataJson.filehashes | Add-Member -MemberType NoteProperty -Name $ModFilePath.FullName -Value $ModFileHash.Hash -Force
                    }
                    else {
                        Write-Verbose "Skipping: $ModFilePath"
                    }
                }
            }
        }
        $EnvMetadataJson | ConvertTo-Json -Depth 100 | Out-File "$ProjectDirectory\temp\filehashes\$EnvHashesFileName.json"
    }

    if ($ValidateOnly) {
        Test-AzResourceGroupDeployment -ResourceGroupName $ResourceGroupName @TemplateParameters -Verbose
    }
    else {
        if (-not $subscriptiondeploy) {
            New-AzResourceGroupDeployment -Name $DeploymentName `
                -ResourceGroupName $ResourceGroupName `
                @TemplateParameters -Force -Verbose -ErrorVariable ErrorMessages        
        }
        else {
            New-AzDeployment @TemplateParameters -Name $DeploymentName -Location $ResourceGroupLocation -Verbose -ErrorVariable ErrorMessages
        }
        if ($ErrorMessages) {
            Write-Output '', 'Template deployment returned the following errors:', @(@($ErrorMessages) | ForEach-Object { $_.Exception.Message.TrimEnd("`r`n") })
        }
    }
}

New-Alias -Name OsirisDeploy -Value Start-OsDeploy -Force

# This allows the -Modules or -m parameter to dynamically offer tab completition for module and module names.
Register-ArgumentCompleter -CommandName Start-OsDeploy -ParameterName Modules -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete)
    $ModulesLocation = (Get-Location | Select-Object -ExpandProperty Path).ToString() + '\modules'
    $foldernames = @()
    $foldernames += Get-ChildItem $ModulesLocation -Directory | Select-Object -ExpandProperty Name
    $foldernames | Select-Object -Unique | Sort-Object | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
        "$_"
    }
}

Register-ArgumentCompleter -CommandName Start-OsDeploy -ParameterName ResourceGroupLocation -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete)
    if (-not (Test-Path "$env:TEMP\Osiris\AzLocations.json") -or -not (Get-Item "$env:TEMP\AzLocations.json" | Select-Object -ExpandProperty LastWriteTime ) -gt ((Get-Date).AddDays(-30))) {
        New-Item -ItemType Directory "$env:TEMP\Osiris" -ErrorAction SilentlyContinue
        $AzLocations = Get-AzLocation | Select-Object -ExpandProperty Location | Where-Object { $_ -like "*us" } | Sort-Object
        $AzLocations | ConvertTo-Json -Depth 100 | Out-File "$env:TEMP\AzLocations.json" -Force
    }
    $AzLocations = Get-Content "$env:TEMP\Osiris\AzLocations.json" -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    $AzLocations | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
        "$_"
    }
}