In our last episode, CloudDoc learned how to scan and diagnose an Azure environment. It collected resources, fed them to an AI model with a strict schema, and came back with structured findings, severity levels, categories, prescriptions, and even the PowerShell commands to fix them.

But there was a catch. Our doctor could tell you what was wrong, hand you the prescription, and then… wave goodbye. You were on your own.

That changes today. 💊

Today CloudDoc gets its hands dirty. It’s going to read those findings, show you a proper diagnosis report, and, if you give the green light actually fix the issues. With a safety net, of course. Because even the best doctors carry malpractice insurance. 😉

🎬 Action steps are marked with this icon.

📒 Technical deep-dives are marked with this icon.

⚠️ This post assumes you’ve completed post #54. You should have CloudDocSchema.ps1, Analysis.ps1, and Diagnose.ps1 ready.

Let’s scrub in! 🧤

Oh, and in the case you’ve might missed my previous blog… (hopefully you are a trustworthy follower.. but you never know.. I’ll link it down here)

What We’re Starting With

From post the previous post we should have the files below. If you haven’t please check out the previous blog as we’re building on top of that.

FileWhat it does
CloudDocSchema.ps1Diagnosis schema – severity, category, fixCommand, autoFixable
Analysis.ps1Get-AzureSnapshot – scans subscription and resource group
Diagnose.ps1Invoke-Diagnosis – sends snapshot to Azure OpenAI

Phase 1: The Diagnosis Report

Raw JSON in the console is not a report. Let’s make CloudDoc present its findings properly, color-coded by severity, with auto-fix indicators.

🎬 Create a new file called Show-Diagnosis.ps1:

function Show-Diagnosis {
    param($Diagnosis)
 
    $colors = @{
        critical = "Red"; high = "Yellow"
        medium = "Cyan"; low = "DarkGray"
    }
 
    Write-Host "CLOUDDOC DIAGNOSIS REPORT" -ForegroundColor Cyan
 
    $statusColor = switch ($Diagnosis.summary.status) {
        "healthy"  { "Green" }
        "at-risk"  { "Yellow" }
        "critical" { "Red" }
    }
 
    Write-Host "Health Score: $($Diagnosis.summary.healthScore)/100"
        -ForegroundColor $statusColor
 
    foreach ($finding in $Diagnosis.findings) {
        $color = $colors[$finding.severity]
        $icon = if ($finding.autoFixable) { "[AUTO-FIX]" }
               else { "[MANUAL]" }
 
        Write-Host "[$($finding.severity.ToUpper())] $($finding.resource)"
            -ForegroundColor $color
        Write-Host "  Diagnosis: $($finding.diagnosis)"
        Write-Host "  Rx: $($finding.prescription)"
        Write-Host "  $icon $($finding.fixCommand)"
    }
 
    $autoCount = ($Diagnosis.findings |
        Where-Object { $_.autoFixable }).Count
    Write-Host "Summary: $autoCount auto-fixable"
}

📒 $colors[$finding.severity] just works because the enum from the schema guarantees exact values. No string parsing needed. Structured output pays dividends in your UI code.

Phase 2: The Treatment

CloudDoc is going to execute fix commands. But responsibly: filter, preview, snapshot, confirm, execute, report.

🎬 Create a new file called Invoke-Treatment.ps1:

function Get-ResourceState {
    param([string]$ResourceName, [string]$ResourceType,
          [string]$ResourceGroupName)
 
    switch -Wildcard ($ResourceType) {
        "Microsoft.Storage/storageAccounts" {
            return Get-AzStorageAccount -Name $ResourceName
                -ResourceGroupName $ResourceGroupName
                -ErrorAction SilentlyContinue
        }
        "Microsoft.Web/sites" {
            return Get-AzWebApp -Name $ResourceName
                -ResourceGroupName $ResourceGroupName
                -ErrorAction SilentlyContinue
        }
        default {
            return Get-AzResource -Name $ResourceName
                -ResourceGroupName $ResourceGroupName
                -ErrorAction SilentlyContinue
        }
    }
}

📒 Get-ResourceState is our insurance policy. Before changing anything, we capture the current state for rollback.

function Invoke-Treatment {
    param($Diagnosis, [string]$ResourceGroupName,
          [switch]$DryRun)
 
    $autoFixes = @($Diagnosis.findings |
        Where-Object { $_.autoFixable -eq $true })
 
    if ($autoFixes.Count -eq 0) {
        Write-Host "No auto-fixable issues."
        return @()
    }
 
    Write-Host "TREATMENT PLAN ($($autoFixes.Count) fixes):"
        -ForegroundColor Yellow
    foreach ($fix in $autoFixes) {
        Write-Host "  [$($fix.severity.ToUpper())] $($fix.resource)"
        Write-Host "    Rx: $($fix.prescription)"
        Write-Host "    Cmd: $($fix.fixCommand)"
    }
 
    if ($DryRun) {
        Write-Host "DRY RUN complete. No changes made."
            -ForegroundColor Cyan
        return @()
    }
 
    $confirm = Read-Host "Apply all fixes? [Y/N]"
    if ($confirm -ne "Y") { return @() }
 
    $rollbackLog = @()
    foreach ($fix in $autoFixes) {
        Write-Host "Treating: $($fix.resource)..."
            -ForegroundColor Yellow
        $snapshot = Get-ResourceState -ResourceName $fix.resource
            -ResourceType $fix.resourceType
            -ResourceGroupName $ResourceGroupName
        try {
            Invoke-Expression $fix.fixCommand
            Write-Host "  Fixed: $($fix.prescription)"
                -ForegroundColor Green
            $rollbackLog += [PSCustomObject]@{
                Resource = $fix.resource
                ResourceType = $fix.resourceType
                Snapshot = $snapshot
                Status = "Applied"
                Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
            }
        }
        catch {
            Write-Host "  FAILED: $_" -ForegroundColor Red
            $rollbackLog += [PSCustomObject]@{
                Resource = $fix.resource
                Status = "Failed: $_"
            }
        }
    }
    return $rollbackLog
}

📒 The -DryRun switch shows exactly what would happen without touching anything. Always dry-run first on production!

Phase 3: The Rollback

Sometimes a fix doesn’t go as planned. CloudDoc keeps receipts — every fix gets a snapshot.

🎬 Add this function to Invoke-Treatment.ps1:

function Invoke-Rollback {
    param([Parameter(Mandatory)][array]$RollbackLog,
          [string]$ResourceGroupName)
 
    $applied = @($RollbackLog |
        Where-Object Status -eq "Applied")
    if ($applied.Count -eq 0) {
        Write-Host "Nothing to rollback."
        return
    }
 
    Write-Host "ROLLBACK PLAN ($($applied.Count) to revert):"
        -ForegroundColor Red
    foreach ($entry in $applied) {
        Write-Host "  $($entry.Resource)" -ForegroundColor Yellow
    }
 
    $confirm = Read-Host "Revert all changes? [Y/N]"
    if ($confirm -ne "Y") { return }
 
    foreach ($entry in $applied) {
        if ($null -eq $entry.Snapshot) {
            Write-Host "SKIP: No snapshot" -ForegroundColor Red
            continue
        }
        try {
            switch -Wildcard ($entry.ResourceType) {
                "Microsoft.Storage/storageAccounts" {
                    Set-AzStorageAccount -Name $entry.Resource `
                        -ResourceGroupName $ResourceGroupName `
                        -AllowBlobPublicAccess \
                            $entry.Snapshot.AllowBlobPublicAccess `
                        -MinimumTlsVersion \
                            $entry.Snapshot.MinimumTlsVersion
                }
                default {
                    Write-Host "SKIP: No auto-rollback for type"
                    continue
                }
            }
            Write-Host "Reverted: $($entry.Resource)"
                -ForegroundColor Green
        }
        catch {
            Write-Host "ROLLBACK FAILED: $_" -ForegroundColor Red
        }
    }
}

📒 The rollback replays the original property values from the snapshot. For storage accounts: AllowBlobPublicAccess, MinimumTlsVersion, EnableHttpsTrafficOnly.

⚠️ Rollback is not a time machine. It only reverts what CloudDoc changed.

The Full Agent: Invoke-CloudDoc

One command that ties #54 and #55 together: scan, diagnose, show, treat, save rollback log.

🎬 Create Invoke-CloudDoc.ps1:

. "$PSScriptRoot\CloudDocSchema.ps1"
. "$PSScriptRoot\Analysis.ps1"
. "$PSScriptRoot\Diagnose.ps1"
. "$PSScriptRoot\Show-Diagnosis.ps1"
. "$PSScriptRoot\Invoke-Treatment.ps1"
 
function Invoke-CloudDoc {
    param(
        [Parameter(Mandatory)][string]$SubscriptionId,
        [Parameter(Mandatory)][string]$ResourceGroupName,
        [switch]$DryRun, [switch]$AutoFix
    )
 
    Write-Host "CloudDoc is scanning..." -ForegroundColor Cyan
 
    $snapshot = Get-AzureSnapshot \
        -SubscriptionId $SubscriptionId \
        -ResourceGroupName $ResourceGroupName
    $diagnosis = Invoke-Diagnosis \
        -Snapshot $snapshot -Schema $diagnosisSchema
 
    Show-Diagnosis -Diagnosis $diagnosis
 
    if ($DryRun) {
        Invoke-Treatment -Diagnosis $diagnosis \
            -ResourceGroupName $ResourceGroupName -DryRun
        return $diagnosis
    }
 
    $rollbackLog = $null
    if ($AutoFix) {
        $rollbackLog = Invoke-Treatment -Diagnosis $diagnosis \
            -ResourceGroupName $ResourceGroupName
    } else {
        $treat = Read-Host "Treat issues? [Y/N]"
        if ($treat -eq "Y") {
            $rollbackLog = Invoke-Treatment \
                -Diagnosis $diagnosis \
                -ResourceGroupName $ResourceGroupName
        }
    }
 
    if ($rollbackLog -and $rollbackLog.Count -gt 0) {
        $path = "CloudDoc_Rollback_$(Get-Date \
            -Format 'yyyyMMdd_HHmmss').json"
        $rollbackLog | ConvertTo-Json -Depth 10 | Out-File $path
        Write-Host "Rollback log: $path" -ForegroundColor DarkGray
        $script:LastRollbackLog = $rollbackLog
    }
    return $diagnosis
}

📒 All dot-source paths are local. Everything CloudDoc needs is in one folder.

Running CloudDoc End-to-End

🎬 Follow these steps for the full workflow:

Step 1: Always dry-run first (adjust variables to your liking)

. .\Invoke-CloudDoc.ps1
 
Invoke-CloudDoc -SubscriptionId "xxxx" -ResourceGroupName "rg-prod" -DryRun

You should be presented with the outcome of CloudDoc like in my example below: (unfortunately the doctor doesn’t seem to be very happy!)

Step 2: Apply fixes interactively

Invoke-CloudDoc -SubscriptionId "xxxx" -ResourceGroupName "rg-prod"

In my situation there is one finding (but needs manual fixing as the risk is considered ‘too-high’) But you can modify the CloudDoc so it will still do it for you

⚠️ If you want you can modify the file ‘diagnose.ps1’ to deal with it so it can fix it for you

As I’m showing in the screenshot below it can now also fix it automatically:

If you want to do this as well just instruct our agent to do so! You can use the prompt below to place it in the agent prompt:

You are CloudDoc, an Azure infrastructure diagnostic agent.
You receive a snapshot of Azure resources and produce a medical-style diagnosis.

Rules:
- fixCommand MUST be a valid PowerShell command using Az module cmdlets (e.g. Set-AzStorageAccount, Set-AzWebApp). NEVER use Azure CLI (az) commands.
- Set autoFixable=true for any fix that is non-destructive and can be safely executed via Invoke-Expression in PowerShell. This includes property changes like disabling public access, enforcing HTTPS, upgrading TLS versions.
- Set autoFixable=false ONLY for destructive operations (deleting resources, removing data) or changes requiring manual review (network topology, access policies).
- Severity: critical = data loss or security breach imminent, high = significant risk, medium = best practice violation, low = optimization opportunity.
- Focus on: public access, missing encryption, open NSG ports, missing backups, deprecated TLS, HTTP-only endpoints.
- If everything looks healthy, return an empty findings array with a high health score.

🎬 Now, let the doc fix it by running without ‘dryrun’

You should be presented with the outcome below:

Next you can validate by running the command below in azure

az storage account show --name xxxxxxxxxxx--resource-group xxxxx

In my case you can see clouddoc cured us! 😉

Safety Notes

⚠️ Never run -AutoFix on production without a dry-run first.

⚠️ Review fix commands before applying. Set-AzStorageAccount is safe. Remove-AzResource is not.

⚠️ The rollback log is your friend. Save it, version it, pipe it to your artifact store.

⚠️ Rollback support grows over time. Add more cases to the switch in Invoke-Rollback as needed.

Summary

In this second installment of Building an AI-Powered Azure Doctor with PowerShell, CloudDoc evolves from a diagnostic tool into a fully controlled remediation agent.

Previously, CloudDoc could scan an Azure environment and return a structured AI-generated diagnosis, including health scores, severity levels, and suggested PowerShell fix commands. However, it stopped at recommendations.

In this update, the system becomes more practical and operational:

First, a new reporting layer (Show-Diagnosis.ps1) transforms raw JSON output into a readable, color-coded console report with clear severity indicators, health status, and auto-fix labels.

Next, CloudDoc introduces controlled remediation through Invoke-Treatment.ps1. It can now execute fix commands, but only after safeguards such as dry-run mode, user confirmation, and resource snapshots are applied. These snapshots act as a rollback mechanism in case anything goes wrong.

Rollback functionality is also added, allowing previously applied changes to be reversed based on stored state logs, making the system safer for real-world use.

Finally, everything is unified in Invoke-CloudDoc, which orchestrates the full workflow: scanning an Azure subscription, generating a diagnosis via AI, displaying a formatted report, optionally applying fixes, and saving rollback logs.

Overall, CloudDoc shifts from “diagnose and advise” to a controlled “diagnose, fix, and recover” system for Azure environments, built with safety and traceability in mind.

⚠️ Last tip from the doctor, use it safely, don’t just rely on what it does that it will do it for you. It’s about showing the concepts on what you can do with AI if you structure your data correctly from day 1.

Leave a Reply

Your email address will not be published. Required fields are marked *