Introduction and Recap
Now that your environment is ready, let's deploy CIS benchmarks to your Windows servers and learn how to scale from one to hundreds of instances. If you're like me, you probably tested that MOF file we created in Part 1 on your test instance and watched 300+ security settings magically apply themselves. Pretty satisfying, right? But the real power comes when we can do this across our entire fleet without touching a single RDP session.
Quick Recap from Our Journey So Far
In Part 1, we:
- Set up IAM roles and S3 buckets to store our configurations
- Installed the CisDsc module and explored what it can do
- Created our first DSC configuration targeting CIS Level 1
- Uploaded that MOF file to S3 (remember
localhost.mof
?) - Understood how Systems Manager, DSC, and S3 work together
In Part 2 (you did read that, right?), we:
- Verified Systems Manager can actually see and manage our instances
- Tested network connectivity to AWS endpoints
- Ensured SSM Agent is running and registered
- Ran test commands to confirm everything works
If you skipped Part 2 and your instances aren't showing up in Systems Manager Fleet Manager with an "Online" status, stop here and go back. Seriously. The rest of this won't work without that foundation.
What You'll Learn Today
Today we're going operational. You'll learn how to:
- Deploy configurations using Systems Manager State Manager
- Monitor compliance in real-time (and actually understand what failed)
- Scale your deployment strategy without melting your servers
- Optimize for performance and cost (because cloud bills are real)
Fair warning: we're going to hit some bumps. DSC is powerful but quirky, and Systems Manager adds its own personality to the mix. I'll share the gotchas I've discovered through trial and error (emphasis on error).
Pre-flight Check
Before we dive in, let's make sure you're ready. If you're using the AWS Console, navigate to Systems Manager > Node Tools > Fleet Manager to verify your instances show as "Online".
From your local machine, you can also run this quick check:
# Verify your instance is visible to Systems Manager
aws ssm describe-instance-information \
--filters "Key=InstanceIds,Values=i-YOUR-INSTANCE-ID" \
--query 'InstanceInformationList[0].[InstanceId,PingStatus,AgentVersion]' \
--output table
You should see:
- Your instance ID
- PingStatus: Online
- A version number for the agent
If you see "Connection Lost" or nothing at all, go back to Part 2. Don't worry, we'll wait.
Creating Production-Ready DSC Configurations
Let's level up from our basic configuration. Production environments need logging, error handling, and flexibility.
Complex DSC Configuration with Logging
First, let's create a more robust configuration that actually tells us what it's doing. Also, not all servers are created equal. Your domain controllers have different security requirements than your web servers. Finally, CIS benchmarks aren't enough, oftentimes you need something additional. Here's a configuration that applies logging for cloudwatch, different server roles, and modifies the registry direct.
# Load all required DSC modules FIRST
Write-Host "Loading DSC modules..." -ForegroundColor Cyan
Import-Module PSDesiredStateConfiguration -Force
Import-Module CisDsc -Force -ErrorAction SilentlyContinue
Import-Module SecurityPolicyDsc -Force -ErrorAction SilentlyContinue
Import-Module AuditPolicyDsc -Force -ErrorAction SilentlyContinue
Write-Host "✓ All DSC modules loaded" -ForegroundColor Green
Configuration ComprehensiveSecurity {
param(
[string]$NodeName = 'localhost',
[string]$ServerRole = 'MemberServer',
[string]$LogPath = 'C:\Logs\DSC'
)
Import-DscResource -ModuleName PSDesiredStateConfiguration
Import-DscResource -ModuleName CisDsc
Import-DscResource -ModuleName SecurityPolicyDsc
Import-DscResource -ModuleName AuditPolicyDsc
Node $NodeName {
# Ensure log directory exists
File DSCLogDirectory {
Ensure = 'Present'
Type = 'Directory'
DestinationPath = $LogPath
Force = $true
}
# Configure LCM consistently
LocalConfigurationManager {
RefreshMode = 'Push'
ConfigurationMode = 'ApplyAndMonitor'
RebootNodeIfNeeded = $false
ActionAfterReboot = 'ContinueConfiguration'
}
# DSC Execution Logger - Start
Script DSCExecutionStart {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "=== Starting DSC Configuration Application ===" -Level "INFO"
Write-DSCLog "Server Role: $using:ServerRole" -Level "INFO"
Write-DSCLog "Node Name: $using:NodeName" -Level "INFO"
Write-DSCLog "Log Path: $using:LogPath" -Level "INFO"
Write-DSCLog "Execution Time: $(Get-Date)" -Level "INFO"
# Log system information
$osInfo = Get-CimInstance -ClassName Win32_OperatingSystem
Write-DSCLog "OS: $($osInfo.Caption) $($osInfo.Version)" -Level "INFO"
Write-DSCLog "Computer Name: $($env:COMPUTERNAME)" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "DSC Start Logger" } }
DependsOn = '[File]DSCLogDirectory'
}
# Common CIS parameters
$CommonParams = @{
Cis2316AccountsRenameGuestaccount = "GuestRenamed"
Cis2374LegalNoticeText = "Authorized access only - All activity monitored"
Cis2375LegalNoticeCaption = "Security Warning"
Cis2376CachedLogonsCount = "2"
Cis1849ScreensaverGracePeriod = "5"
}
# Role-specific CIS baseline with account policy exclusions
switch ($ServerRole) {
'DomainController' {
Script DCConfigStart {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "Applying Domain Controller CIS baseline" -Level "INFO"
Write-DSCLog "Excluding controls for DC-specific requirements" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "DC Config Start" } }
DependsOn = '[Script]DSCExecutionStart'
}
CIS_Microsoft_Windows_Server_2022_Member_Server_Release_21H2 DCBaseline {
Cis2316AccountsRenameGuestaccount = $CommonParams.Cis2316AccountsRenameGuestaccount
Cis2374LegalNoticeText = $CommonParams.Cis2374LegalNoticeText
Cis2375LegalNoticeCaption = $CommonParams.Cis2375LegalNoticeCaption
Cis2376CachedLogonsCount = $CommonParams.Cis2376CachedLogonsCount
Cis1849ScreensaverGracePeriod = $CommonParams.Cis1849ScreensaverGracePeriod
ExcludeList = @(
'2.2.21', # Deny access from network (DCs need this)
'2.2.26', # Deny logon as service (some DC services)
'2.3.1.5', # Administrator account status (needed for break-glass)
'2.3.1.1', # Administrator account status (alternative ID)
'1.1.1', # Account lockout duration
'1.1.2', # Account lockout threshold
'1.1.3', # Reset account lockout counter
'1.1.4', # Account lockout duration (alternative)
'1.1.5', # Account lockout threshold (alternative)
'1.1.6', # Reset account lockout counter (alternative)
'1.2.1', # Account lockout duration (variant)
'1.2.2', # Account lockout threshold (variant)
'1.2.3' # Reset account lockout counter (variant)
)
DependsOn = '[Script]DCConfigStart'
}
# Remove the AccountPolicy resource that's causing conflicts
Script DCConfigComplete {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "Domain Controller CIS baseline application completed" -Level "INFO"
Write-DSCLog "Account policy excluded to avoid conflicts" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "DC Config Complete" } }
DependsOn = '[CIS_Microsoft_Windows_Server_2022_Member_Server_Release_21H2]DCBaseline'
}
}
'WebServer' {
Script WebConfigStart {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "Applying Web Server CIS baseline" -Level "INFO"
Write-DSCLog "Excluding controls for IIS requirements" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "Web Config Start" } }
DependsOn = '[Script]DSCExecutionStart'
}
CIS_Microsoft_Windows_Server_2022_Member_Server_Release_21H2 WebBaseline {
Cis2316AccountsRenameGuestaccount = $CommonParams.Cis2316AccountsRenameGuestaccount
Cis2374LegalNoticeText = $CommonParams.Cis2374LegalNoticeText
Cis2375LegalNoticeCaption = $CommonParams.Cis2375LegalNoticeCaption
Cis2376CachedLogonsCount = $CommonParams.Cis2376CachedLogonsCount
Cis1849ScreensaverGracePeriod = $CommonParams.Cis1849ScreensaverGracePeriod
ExcludeList = @(
'2.3.1.5', # Administrator account status (needed for break-glass)
'2.3.1.1', # Administrator account status (alternative ID)
'5.1', # Windows Firewall (IIS manages its own)
'18.9.47.5.1', # WinRM for remote management
'1.1.1', # Account lockout duration
'1.1.2', # Account lockout threshold
'1.1.3', # Reset account lockout counter
'1.1.4', # Account lockout duration (alternative)
'1.1.5', # Account lockout threshold (alternative)
'1.1.6', # Reset account lockout counter (alternative)
'1.2.1', # Account lockout duration (variant)
'1.2.2', # Account lockout threshold (variant)
'1.2.3' # Reset account lockout counter (variant)
)
DependsOn = '[Script]WebConfigStart'
}
# Remove the AccountPolicy resource that's causing conflicts
Script WebConfigComplete {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "Web Server CIS baseline application completed" -Level "INFO"
Write-DSCLog "Account policy excluded to avoid conflicts" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "Web Config Complete" } }
DependsOn = '[CIS_Microsoft_Windows_Server_2022_Member_Server_Release_21H2]WebBaseline'
}
}
Default {
Script MemberConfigStart {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "Applying Member Server CIS baseline" -Level "INFO"
Write-DSCLog "Standard member server configuration" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "Member Config Start" } }
DependsOn = '[Script]DSCExecutionStart'
}
CIS_Microsoft_Windows_Server_2022_Member_Server_Release_21H2 MemberBaseline {
Cis2316AccountsRenameGuestaccount = $CommonParams.Cis2316AccountsRenameGuestaccount
Cis2374LegalNoticeText = $CommonParams.Cis2374LegalNoticeText
Cis2375LegalNoticeCaption = $CommonParams.Cis2375LegalNoticeCaption
Cis2376CachedLogonsCount = $CommonParams.Cis2376CachedLogonsCount
Cis1849ScreensaverGracePeriod = $CommonParams.Cis1849ScreensaverGracePeriod
ExcludeList = @(
'2.3.1.5', # Administrator account status (needed for break-glass)
'2.3.1.1', # Administrator account status (alternative ID)
'18.9.4.1', # AutoAdminLogon (some automation requires this)
'1.1.1', # Account lockout duration
'1.1.2', # Account lockout threshold
'1.1.3', # Reset account lockout counter
'1.1.4', # Account lockout duration (alternative)
'1.1.5', # Account lockout threshold (alternative)
'1.1.6', # Reset account lockout counter (alternative)
'1.2.1', # Account lockout duration (variant)
'1.2.2', # Account lockout threshold (variant)
'1.2.3' # Reset account lockout counter (variant)
)
DependsOn = '[Script]MemberConfigStart'
}
# Remove the AccountPolicy resource that's causing conflicts
Script MemberConfigComplete {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "Member Server CIS baseline application completed" -Level "INFO"
Write-DSCLog "Account policy excluded to avoid conflicts" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "Member Config Complete" } }
DependsOn = '[CIS_Microsoft_Windows_Server_2022_Member_Server_Release_21H2]MemberBaseline'
}
}
}
# Custom registry settings that complement CIS
Script CustomRegistryStart {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "Applying custom registry security settings" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "Custom Registry Start" } }
DependsOn = '[File]DSCLogDirectory'
}
Registry DisableAutorun {
Key = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer'
ValueName = 'NoDriveTypeAutoRun'
ValueData = 255
ValueType = 'Dword'
Ensure = 'Present'
DependsOn = '[Script]CustomRegistryStart'
}
Registry DisableLLMNR {
Key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient'
ValueName = 'EnableMulticast'
ValueData = 0
ValueType = 'Dword'
Ensure = 'Present'
DependsOn = '[Script]CustomRegistryStart'
}
Script CustomRegistryComplete {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "Custom registry settings applied successfully" -Level "INFO"
Write-DSCLog "- Disabled autorun for all drive types" -Level "INFO"
Write-DSCLog "- Disabled LLMNR for security" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "Custom Registry Complete" } }
DependsOn = '[Registry]DisableAutorun', '[Registry]DisableLLMNR'
}
# Additional audit policies
Script AuditPolicyStart {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "Configuring additional audit policies" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "Audit Policy Start" } }
DependsOn = '[File]DSCLogDirectory'
}
AuditPolicySubcategory AccountLockout {
Name = 'Account Lockout'
AuditFlag = 'Failure'
Ensure = 'Present'
DependsOn = '[Script]AuditPolicyStart'
}
AuditPolicySubcategory LogonEvents {
Name = 'Logon'
AuditFlag = 'Failure'
Ensure = 'Present'
DependsOn = '[Script]AuditPolicyStart'
}
if ($ServerRole -eq 'DomainController') {
AuditPolicySubcategory DirectoryServiceAccess {
Name = 'Directory Service Access'
AuditFlag = 'Failure'
Ensure = 'Present'
DependsOn = '[Script]AuditPolicyStart'
}
Script DCAuditComplete {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "Domain Controller audit policies configured" -Level "INFO"
Write-DSCLog "- Account lockout failures" -Level "INFO"
Write-DSCLog "- Logon failures" -Level "INFO"
Write-DSCLog "- Directory service access failures" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "DC Audit Complete" } }
DependsOn = '[AuditPolicySubcategory]DirectoryServiceAccess'
}
} else {
Script StandardAuditComplete {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "Standard audit policies configured" -Level "INFO"
Write-DSCLog "- Account lockout failures" -Level "INFO"
Write-DSCLog "- Logon failures" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "Standard Audit Complete" } }
DependsOn = '[AuditPolicySubcategory]LogonEvents'
}
}
# Final completion logging
Script DSCExecutionComplete {
SetScript = {
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO", [string]$LogPath = "C:\Logs\DSC\dsc-execution.log")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
$logDir = Split-Path $LogPath
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $LogPath -Value $logEntry -ErrorAction SilentlyContinue
Write-Host $logEntry
}
Write-DSCLog "=== DSC Configuration Application Completed ===" -Level "INFO"
Write-DSCLog "Server Role: $using:ServerRole" -Level "INFO"
Write-DSCLog "Completion Time: $(Get-Date)" -Level "INFO"
Write-DSCLog "Configuration applied successfully" -Level "INFO"
$summaryPath = "$using:LogPath\dsc-summary-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
$summary = "DSC Configuration Summary`nServer Role: $using:ServerRole`nNode Name: $using:NodeName`nCompletion Time: $(Get-Date)`nStatus: SUCCESS"
$summary | Out-File -FilePath $summaryPath -Encoding UTF8
Write-DSCLog "Summary report generated: $summaryPath" -Level "INFO"
}
TestScript = { $false }
GetScript = { @{ Result = "DSC Execution Complete" } }
DependsOn = '[Script]CustomRegistryComplete'
}
}
}
# Generate MOF files for all roles
Write-Host "Starting MOF generation process..." -ForegroundColor Cyan
@('WebServer', 'DomainController', 'MemberServer') | ForEach-Object {
$Role = $_
$OutputPath = ".\MOF\Comprehensive\$Role"
Write-Host "Generating MOF for $Role role..." -ForegroundColor Yellow
try {
if (!(Test-Path $OutputPath)) {
New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
}
ComprehensiveSecurity -ServerRole $Role -NodeName 'localhost' -OutputPath $OutputPath
Write-Host "✓ Generated comprehensive security configuration for $Role role" -ForegroundColor Green
$mofFile = Join-Path $OutputPath "localhost.mof"
if (Test-Path $mofFile) {
$mofSize = (Get-Item $mofFile).Length
Write-Host " MOF Size: $mofSize bytes" -ForegroundColor Gray
} else {
Write-Host " WARNING: MOF file not found!" -ForegroundColor Red
}
} catch {
Write-Host "✗ Failed to generate MOF for $Role role" -ForegroundColor Red
Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red
}
}
Write-Host "MOF generation process completed!" -ForegroundColor Cyan
Write-Host "Upload the MOF files to S3 and update your Systems Manager associations." -ForegroundColor White
Notice the ConfigurationMode = 'ApplyAndMonitor'
? That's intentional. In production, you often want to detect drift without automatically fixing it, especially during business hours. We'll handle auto-remediation through Systems Manager scheduling.
Finally, add a MOF that's lightweight to test your logic before actually throwing a big configuration at things. Save this as test-lightweight.mof
in the same .\MOF
folder you put the others.
/*
@TargetNode='localhost'
@GeneratedBy=TestUser
@GenerationDate=01/01/2024 12:00:00
@GenerationHost=TestHost
*/
instance of MSFT_RoleResource as $MSFT_RoleResource1ref
{
ResourceID = "[WindowsFeature]TelnetClient";
Ensure = "Absent";
Name = "Telnet-Client";
ModuleName = "PSDesiredStateConfiguration";
ModuleVersion = "1.0";
ConfigurationName = "TestLightweightConfiguration";
};
instance of MSFT_RegistryResource as $MSFT_RegistryResource1ref
{
ResourceID = "[Registry]TestRegKey";
ValueName = "DSCTestValue";
ValueType = "String";
Key = "HKEY_LOCAL_MACHINE\\SOFTWARE\\DSCTest";
ValueData = {"TestConfiguration"};
Ensure = "Present";
ModuleName = "PSDesiredStateConfiguration";
ModuleVersion = "1.0";
ConfigurationName = "TestLightweightConfiguration";
};
instance of MSFT_ServiceResource as $MSFT_ServiceResource1ref
{
ResourceID = "[Service]Spooler";
Name = "Spooler";
State = "Running";
StartupType = "Automatic";
ModuleName = "PSDesiredStateConfiguration";
ModuleVersion = "1.0";
ConfigurationName = "TestLightweightConfiguration";
};
instance of OMI_ConfigurationDocument
{
Version="2.0.0";
MinimumCompatibleVersion = "1.0.0";
CompatibleVersionAdditionalProperties= {"Omi_BaseResource:ConfigurationName"};
Author="TestUser";
GenerationDate="01/01/2024 12:00:00";
GenerationHost="TestHost";
Name="TestLightweightConfiguration";
};
Upload these new MOFs to S3:
# Upload all MOFs to organized folders
Get-ChildItem -Path .\MOF -Recurse -Filter "*.mof" | ForEach-Object {
$key = "configurations/$($_.Directory.Name)/$($_.Name)"
Write-S3Object -BucketName "systems-manager-windows-server-dsc-configurations" `
-File $_.FullName `
-Key $key
}
# Verify they are all there
Get-S3Object -BucketName "systems-manager-windows-server-dsc-configurations" -Prefix "configurations/" |
Select-Object Key, Size, LastModified |
Format-Table -AutoSize
Deploying via AWS Systems Manager
Now for the fun part - actually deploying these configurations at scale. But first, let's verify Systems Manager can execute commands on your instance:
# Quick test - this should return "Success" status
$InstanceID = "i-YOUR-INSTANCE-ID"
aws ssm send-command `
--document-name "AWS-RunPowerShellScript" `
--targets "Key=instanceids,Values=$InstanceID" `
--parameters 'commands=["echo Hello from Systems Manager"]' `
--query 'Command.CommandId' `
--output text | ForEach-Object {
$cmdId = $_
do {
Start-Sleep 2
$status = aws ssm get-command-invocation --command-id $cmdId --instance-id $InstanceID --query 'Status' --output text
Write-Host "Status: $status"
} while ($status -eq "Pending" -or $status -eq "InProgress")
# Get final output
aws ssm get-command-invocation --command-id $cmdId --instance-id $InstanceID --query '[Status,StandardOutputContent]' --output text
}
If that doesn't work, you know what I'm going to say... Part 2 is calling your name.
Creating the Systems Manager Document
First, we need a Systems Manager document that knows how to apply our DSC configurations. There is a pre-made AWS-ApplyDSCMofs that you can use out of the box, but I tweaked it a bit:
{
"schemaVersion": "2.2",
"description": "Apply CIS Level 1 Baseline using DSC with enhanced error handling - State Manager Compatible",
"parameters": {
"configurationName": {
"type": "String",
"description": "Name of the DSC configuration",
"default": "CISLevel1Production",
"allowedPattern": "^[a-zA-Z0-9_-]+$"
},
"configurationS3Bucket": {
"type": "String",
"description": "S3 bucket containing the MOF file",
"allowedPattern": "^[a-z0-9.-]+$"
},
"configurationS3Key": {
"type": "String",
"description": "S3 key for the MOF file",
"allowedPattern": "^[a-zA-Z0-9!_.*'()/-]+$"
},
"requiredModules": {
"type": "String",
"description": "Comma-separated list of DSC modules to install",
"default": "CisDsc,SecurityPolicyDsc,AuditPolicyDsc,NetworkingDsc,ComputerManagementDsc"
},
"testMode": {
"type": "String",
"description": "Run in test mode with enhanced logging and validation only",
"default": "false",
"allowedValues": ["true", "false"]
},
"complianceCheck": {
"type": "String",
"description": "Run compliance check after applying",
"default": "true",
"allowedValues": ["true", "false"]
},
"EnableVerboseLogging": {
"type": "String",
"description": "Enable verbose logging for troubleshooting",
"default": "false",
"allowedValues": ["true", "false"]
},
"EnableDebugLogging": {
"type": "String",
"description": "Enable debug logging for detailed troubleshooting",
"default": "false",
"allowedValues": ["true", "false"]
},
"ProxyUri": {
"type": "String",
"description": "Optional proxy server URI",
"default": "",
"allowedPattern": "[a-zA-Z0-9\\:\\-_/\\.]*"
},
"RebootBehavior": {
"type": "String",
"description": "Reboot behavior after configuration application",
"default": "Never",
"allowedValues": ["Never", "IfRequired", "Immediately"]
},
"MaxRetryAttempts": {
"type": "String",
"description": "Maximum retry attempts for DSC application",
"default": "3",
"allowedPattern": "^[1-5]$"
}
},
"mainSteps": [
{
"action": "aws:runPowerShellScript",
"name": "applyDSCConfiguration",
"inputs": {
"timeoutSeconds": "10800",
"runCommand": [
"##################################################################################",
"# Optimized DSC Configuration Application Script",
"# Optimizations: Memory management, parallel processing, improved error handling",
"##################################################################################",
"",
"# Optimize PowerShell session configuration",
"$ProgressPreference = 'SilentlyContinue'",
"$ConfirmPreference = 'None'",
"$ErrorActionPreference = 'Stop'",
"[System.GC]::Collect() # Initial garbage collection",
"",
"# Network optimization",
"if (-not [Net.ServicePointManager]::SecurityProtocol.HasFlag([Net.SecurityProtocolType]::Tls12)) {",
" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12",
"}",
"[Net.ServicePointManager]::DefaultConnectionLimit = 100",
"[Net.ServicePointManager]::Expect100Continue = $false",
"",
"# Global variables for performance",
"$global:ProxyUri = '{{ProxyUri}}'",
"$global:EnableVerboseLogging = $false",
"$global:EnableDebugLogging = $false",
"$global:LogBuffer = [System.Collections.Generic.List[string]]::new()",
"",
"# Optimized stack trace with size limit",
"$global:stackTrace = New-Object 'System.Collections.Generic.Stack[string]'",
"Set-Variable -Name 'MaxStackDepth' -Value 50 -Option ReadOnly",
"",
"# Enhanced trap with memory management",
"trap {",
" try {",
" $separator = '#' * 70",
" $errorInfo = @(",
" $separator",
" '# DSC Configuration Error'",
" $separator",
" \"Error: $($_.Exception.Message) (line $($_.InvocationInfo.ScriptLineNumber))\"",
" \"Command: $($_.InvocationInfo.Line.Trim())\"",
" )",
" ",
" if ($global:stackTrace -and $global:stackTrace.GetType().Name -eq 'Stack`1' -and $global:stackTrace.Count -gt 0) {",
" $errorInfo += @($separator, '# Stack Trace', $separator)",
" # Convert stack to array properly with error handling",
" $stackArray = @()",
" try {",
" while ($global:stackTrace.Count -gt 0) {",
" $stackArray += $global:stackTrace.Pop()",
" }",
" $errorInfo += $stackArray",
" } catch {",
" $errorInfo += 'Stack trace unavailable due to error'",
" }",
" }",
" ",
" $errorMessage = $errorInfo -join [Environment]::NewLine",
" Write-Error $errorMessage -ErrorAction Continue",
" ",
" # Log to file if logging enabled",
" if ($env:DSCLogFileName -and (Test-Path (Split-Path $env:DSCLogFileName -ErrorAction SilentlyContinue))) {",
" try { $errorMessage | Out-File -FilePath $env:DSCLogFileName -Append -Encoding UTF8 } catch { }",
" }",
" } catch {",
" Write-Error \"Critical error in error handler: $_\" -ErrorAction Continue",
" } finally {",
" $exitCode = if ($_.InvocationInfo.ScriptLineNumber -gt 0) { $_.InvocationInfo.ScriptLineNumber } else { 1 }",
" [Environment]::Exit($exitCode)",
" }",
"}",
"",
"# Optimized logging function",
"Function Write-DSCLog {",
" [CmdletBinding()]",
" param(",
" [Parameter(Mandatory)]",
" [string]$Message,",
" [ValidateSet('Info', 'Warning', 'Error', 'Debug', 'Verbose')]",
" [string]$Level = 'Info'",
" )",
" ",
" # Early return for disabled logging levels",
" if (($Level -eq 'Verbose' -and -not $global:EnableVerboseLogging) -or",
" ($Level -eq 'Debug' -and -not $global:EnableDebugLogging)) {",
" return",
" }",
" ",
" $timestamp = [DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss.fff')",
" $logEntry = \"[$timestamp] [$Level] $Message\"",
" ",
" # Output to appropriate stream",
" switch ($Level) {",
" 'Error' { Write-Error $logEntry -ErrorAction Continue }",
" 'Warning' { Write-Warning $logEntry }",
" 'Debug' { if ($global:EnableDebugLogging) { Write-Debug $logEntry } }",
" 'Verbose' { if ($global:EnableVerboseLogging) { Write-Verbose $logEntry -Verbose } }",
" default { Write-Output $logEntry }",
" }",
" ",
" # Buffer logging for performance",
" if ($env:DSCLogFileName) {",
" $global:LogBuffer.Add($logEntry)",
" # Flush buffer when it gets large",
" if ($global:LogBuffer.Count -ge 100) {",
" try {",
" $global:LogBuffer | Out-File -FilePath $env:DSCLogFileName -Append -Encoding UTF8",
" $global:LogBuffer.Clear()",
" } catch { <# Ignore logging errors #> }",
" }",
" }",
"}",
"",
"# Stack management with depth control and error handling",
"Function Push-StackTrace {",
" param([string]$Operation)",
" try {",
" if ($global:stackTrace -and $global:stackTrace.GetType().Name -eq 'Stack`1' -and $global:stackTrace.Count -lt $MaxStackDepth) {",
" $global:stackTrace.Push(\"$([DateTime]::Now.ToString('HH:mm:ss')) - $Operation\")",
" }",
" } catch {",
" # Silently ignore stack errors",
" }",
"}",
"",
"Function Pop-StackTrace {",
" try {",
" if ($global:stackTrace -and $global:stackTrace.GetType().Name -eq 'Stack`1' -and $global:stackTrace.Count -gt 0) {",
" [void]$global:stackTrace.Pop()",
" }",
" } catch {",
" # Silently ignore stack errors",
" }",
"}",
"",
"# Initialize logging with optimizations",
"try {",
" [bool]$debugEnabled = [bool]::Parse('{{EnableDebugLogging}}')",
" [bool]$verboseEnabled = [bool]::Parse('{{EnableVerboseLogging}}')",
" ",
" if ($debugEnabled) {",
" $global:EnableDebugLogging = $true",
" $global:EnableVerboseLogging = $true",
" $DebugPreference = 'Continue'",
" $VerbosePreference = 'Continue'",
" ",
" $logDir = \"$env:ProgramData\\DSCLogs\"",
" if (-not (Test-Path $logDir)) {",
" [void](New-Item -Path $logDir -ItemType Directory -Force)",
" }",
" $env:DSCLogFileName = Join-Path $logDir 'DSC-Apply-Configuration.log'",
" } elseif ($verboseEnabled) {",
" $global:EnableVerboseLogging = $true",
" $VerbosePreference = 'Continue'",
" }",
"} catch {",
" Write-Warning \"Failed to initialize logging: $_\"",
"}",
"",
"# Fixed result object as hashtable for dynamic property support",
"$result = @{",
" Status = 'InProgress'",
" ConfigurationName = '{{configurationName}}'",
" StartTime = [DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss')",
" EndTime = $null",
" Messages = [System.Collections.Generic.List[string]]::new()",
" TestMode = ([bool]::Parse('{{testMode}}'))",
" Performance = @{",
" ModuleInstallTime = 0.0",
" DownloadTime = 0.0",
" ValidationTime = 0.0",
" ApplicationTime = 0.0",
" ComplianceTime = 0.0",
" }",
" Environment = @{",
" PowerShellVersion = $PSVersionTable.PSVersion.ToString()",
" OSVersion = [System.Environment]::OSVersion.Version.ToString()",
" MachineName = $env:COMPUTERNAME",
" InstanceId = $env:AWS_SSM_INSTANCE_ID",
" Region = $env:AWS_SSM_REGION_NAME",
" ProcessorCount = $env:NUMBER_OF_PROCESSORS",
" AvailableMemoryGB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 2)",
" }",
" ComplianceCheck = $null",
" Error = $null",
" TotalDuration = 0.0",
"}",
"",
"Write-DSCLog \"Starting optimized DSC configuration: $($result.ConfigurationName)\"",
"Write-DSCLog \"Environment: PS $($result.Environment.PowerShellVersion), $($result.Environment.ProcessorCount) cores, $($result.Environment.AvailableMemoryGB)GB RAM\" -Level Verbose",
"",
"try {",
" # Reinitialize stack trace to ensure it's properly created",
" $global:stackTrace = New-Object 'System.Collections.Generic.Stack[string]'",
" Push-StackTrace 'Main execution'",
" ",
" # Optimized AWS module handling",
" Push-StackTrace 'AWS module setup'",
" Write-DSCLog 'Setting up AWS PowerShell dependencies'",
" ",
" # Check if AWS CLI is available for faster S3 operations",
" $useAWSCLI = $false",
" try {",
" $awsVersion = & aws --version 2>$null",
" if ($LASTEXITCODE -eq 0 -and $awsVersion) {",
" $useAWSCLI = $true",
" Write-DSCLog 'AWS CLI detected - will use for S3 operations' -Level Verbose",
" }",
" } catch { ",
" # AWS CLI not available, will use PowerShell modules",
" }",
" ",
" if (-not $useAWSCLI) {",
" # Minimal AWS module installation",
" $awsModules = @('AWS.Tools.S3')",
" foreach ($module in $awsModules) {",
" if (-not (Get-Module -ListAvailable -Name $module)) {",
" Write-DSCLog \"Installing $module\"",
" Install-Module -Name $module -Force -AllowClobber -Scope AllUsers -Repository PSGallery",
" }",
" Import-Module -Name $module -Force",
" }",
" }",
" ",
" # Proxy setup",
" if ($global:ProxyUri -and $global:ProxyUri -ne '') {",
" try {",
" $proxyUri = [Uri]$global:ProxyUri",
" if (-not $useAWSCLI) {",
" Set-AWSProxy -Hostname $proxyUri.Host -Port $proxyUri.Port",
" }",
" [System.Net.WebRequest]::DefaultWebProxy = New-Object System.Net.WebProxy($global:ProxyUri)",
" $result.Messages.Add(\"Proxy configured: $global:ProxyUri\")",
" } catch {",
" Write-DSCLog \"Proxy setup failed: $($_.Exception.Message)\" -Level Warning",
" }",
" }",
" Pop-StackTrace",
" ",
" # Optimized PowerShell Gallery setup",
" Push-StackTrace 'PowerShell Gallery setup'",
" try {",
" # Check if NuGet is needed",
" if (-not (Get-PackageProvider -Name NuGet -ListAvailable | Where-Object Version -ge '2.8.5.201')) {",
" Write-DSCLog 'Installing NuGet package provider'",
" Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers",
" $result.Messages.Add('Installed NuGet package provider')",
" }",
" ",
" # Set PSGallery as trusted if needed",
" $gallery = Get-PSRepository -Name PSGallery",
" if ($gallery.InstallationPolicy -ne 'Trusted') {",
" Set-PSRepository -Name PSGallery -InstallationPolicy Trusted",
" $result.Messages.Add('Set PowerShell Gallery as trusted')",
" }",
" } catch {",
" Write-DSCLog \"PowerShell Gallery setup failed: $_\" -Level Warning",
" throw",
" }",
" Pop-StackTrace",
" ",
" # Optimized module installation with sequential processing (not parallel for PowerShell 5.1 compatibility)",
" $moduleTimer = [System.Diagnostics.Stopwatch]::StartNew()",
" Push-StackTrace 'Module installation'",
" ",
" $modules = '{{requiredModules}}' -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }",
" Write-DSCLog \"Processing $($modules.Count) DSC modules\"",
" ",
" # Install modules sequentially for better compatibility",
" foreach ($moduleName in $modules) {",
" if ($moduleName) {",
" Write-DSCLog \"Processing DSC module: $moduleName\" -Level Verbose",
" Push-StackTrace \"Installing module: $moduleName\"",
" ",
" $existingModule = Get-Module -ListAvailable -Name $moduleName",
" if (-not $existingModule) {",
" try {",
" $installStart = Get-Date",
" Install-Module -Name $moduleName -Force -AllowClobber -Scope AllUsers -Repository PSGallery",
" $installDuration = ((Get-Date) - $installStart).TotalSeconds",
" Write-DSCLog \"Module $moduleName installed in $installDuration seconds\"",
" $result.Messages.Add(\"Installed: $moduleName ($installDuration sec)\")",
" } catch {",
" Write-DSCLog \"Failed to install module $moduleName`: $($_.Exception.Message)\" -Level Warning",
" $result.Messages.Add(\"WARNING: Failed to install $moduleName\")",
" }",
" } else {",
" Write-DSCLog \"Module $moduleName already available (v$($existingModule.Version))\" -Level Verbose",
" }",
" ",
" # Import with verification",
" try {",
" Import-Module -Name $moduleName -Force",
" $imported = Get-Module -Name $moduleName",
" if ($imported) {",
" Write-DSCLog \"Successfully imported: $moduleName (v$($imported.Version))\" -Level Verbose",
" } else {",
" Write-DSCLog \"Module $moduleName not found after import\" -Level Warning",
" }",
" } catch {",
" Write-DSCLog \"Failed to import module $moduleName`: $($_.Exception.Message)\" -Level Warning",
" }",
" ",
" Pop-StackTrace",
" }",
" }",
"",
" # Enhanced Working Directory Management",
" $workDir = 'C:\\SSM-DSC-Temp'",
" Write-DSCLog \"Setting up working directory: $workDir\"",
" if (Test-Path $workDir) {",
" Remove-Item $workDir -Recurse -Force",
" Write-DSCLog 'Cleaned existing working directory' -Level Verbose",
" }",
" New-Item -Path $workDir -ItemType Directory -Force | Out-Null",
"",
" # Optimized S3 download",
" $downloadTimer = [System.Diagnostics.Stopwatch]::StartNew()",
" Push-StackTrace 'S3 download'",
" ",
" $mofPath = Join-Path $workDir 'localhost.mof'",
" Write-DSCLog \"Downloading MOF: s3://{{configurationS3Bucket}}/{{configurationS3Key}}\"",
" ",
" $maxRetries = [int]::Parse('{{MaxRetryAttempts}}')",
" $downloadSuccess = $false",
" ",
" for ($attempt = 1; $attempt -le $maxRetries -and -not $downloadSuccess; $attempt++) {",
" try {",
" if ($useAWSCLI) {",
" # Use AWS CLI for potentially faster downloads",
" $awsCommand = \"aws s3 cp s3://{{configurationS3Bucket}}/{{configurationS3Key}} `\"$mofPath`\"\"",
" $awsResult = Invoke-Expression $awsCommand",
" if ($LASTEXITCODE -eq 0) { $downloadSuccess = $true }",
" } else {",
" # Use PowerShell AWS tools",
" Read-S3Object -BucketName '{{configurationS3Bucket}}' -Key '{{configurationS3Key}}' -File $mofPath",
" $downloadSuccess = $true",
" }",
" ",
" if ($downloadSuccess) {",
" Write-DSCLog \"Download successful on attempt $attempt\"",
" }",
" } catch {",
" Write-DSCLog \"Download attempt $attempt failed: $($_.Exception.Message)\" -Level Warning",
" if ($attempt -lt $maxRetries) {",
" Start-Sleep -Seconds (2 * $attempt) # Exponential backoff",
" }",
" }",
" }",
" ",
" if (-not $downloadSuccess -or -not (Test-Path $mofPath)) {",
" throw \"Failed to download MOF file after $maxRetries attempts\"",
" }",
" ",
" $downloadTimer.Stop()",
" $result.Performance.DownloadTime = $downloadTimer.Elapsed.TotalSeconds",
" $mofSize = (Get-Item $mofPath).Length",
" Write-DSCLog \"Download completed: $mofSize bytes in $($result.Performance.DownloadTime) seconds\"",
" Pop-StackTrace",
" ",
" # Enhanced MOF Validation with proper timer",
" $validationTimer = [System.Diagnostics.Stopwatch]::StartNew()",
" Write-DSCLog 'Validating MOF file content...'",
" try {",
" $mofContent = Get-Content $mofPath -Raw",
" if ($mofContent.Length -eq 0) {",
" throw 'MOF file is empty'",
" }",
" ",
" if ($mofContent -match '@TargetNode') {",
" Write-DSCLog 'MOF file contains valid DSC configuration syntax'",
" } else {",
" Write-Warning 'MOF file may not contain valid DSC configuration'",
" }",
" ",
" # Check for common MOF syntax issues",
" if ($mofContent -match 'ValueData\\s*=\\s*\"[^\"]*\";' -and $mofContent -match 'ValueType\\s*=\\s*\"String\\[\\]\";') {",
" Write-Warning 'Potential type mismatch detected: String value assigned to String[] property'",
" $result.Messages.Add('WARNING: Potential ValueData type mismatch in MOF')",
" }",
" ",
" $mofLines = ($mofContent -split '\\r?\\n').Length",
" Write-DSCLog \"MOF file contains $mofLines lines\"",
" $result.Messages.Add(\"MOF file validation passed ($mofLines lines)\")",
" ",
" # Additional MOF validation",
" Write-DSCLog 'Performing advanced MOF validation...'",
" try {",
" $tempConfigPath = Join-Path $workDir 'temp-validation'",
" New-Item -Path $tempConfigPath -ItemType Directory -Force | Out-Null",
" Copy-Item $mofPath (Join-Path $tempConfigPath 'localhost.mof')",
" ",
" $null = Test-DscConfiguration -Path $tempConfigPath -ErrorAction Stop",
" Write-DSCLog 'MOF syntax validation passed'",
" Remove-Item $tempConfigPath -Recurse -Force -ErrorAction SilentlyContinue",
" } catch {",
" Write-Warning \"MOF syntax validation failed: $($_.Exception.Message)\"",
" $result.Messages.Add(\"WARNING: MOF syntax issue detected: $($_.Exception.Message)\")",
" Remove-Item $tempConfigPath -Recurse -Force -ErrorAction SilentlyContinue",
" }",
" } catch {",
" throw \"MOF file validation failed: $($_.Exception.Message)\"",
" }",
" ",
" $validationTimer.Stop()",
" $result.Performance.ValidationTime = $validationTimer.Elapsed.TotalSeconds",
"",
" # Test mode or application",
" if ([bool]::Parse('{{testMode}}')) {",
" Write-DSCLog '=== TEST MODE: All validations completed ===' -Level Warning",
" $result.Status = 'TestModeSuccess'",
" $result.Messages.Add('Test mode completed successfully')",
" } else {",
" $appTimer = [System.Diagnostics.Stopwatch]::StartNew()",
" Push-StackTrace 'DSC application'",
" ",
" $maxRetries = [int]::Parse('{{MaxRetryAttempts}}')",
" $applied = $false",
" for ($attempt = 1; $attempt -le $maxRetries -and -not $applied; $attempt++) {",
" try {",
" Write-DSCLog \"DSC application attempt $attempt/$maxRetries\"",
" Push-StackTrace \"DSC Application attempt $attempt\"",
" ",
" Start-DscConfiguration -Path $workDir -Wait -Force -Verbose:$global:EnableVerboseLogging",
" $applied = $true",
" Write-DSCLog \"Configuration applied successfully\"",
" Pop-StackTrace",
" ",
" } catch [Microsoft.Management.Infrastructure.CimException] {",
" $cimMsg = $_.Exception.Message",
" Write-DSCLog \"CIM Exception: $cimMsg\" -Level Warning",
" ",
" if ($cimMsg -match 'Convert property.*failed' -and $cimMsg -match 'ValueData') {",
" Write-DSCLog 'Type conversion error - likely MOF ValueData format issue' -Level Error",
" Write-DSCLog 'Suggestion: Ensure ValueData uses array format: ValueData = {\"value\"}' -Level Error",
" }",
" ",
" Pop-StackTrace",
" if ($attempt -eq $maxRetries) { throw }",
" Start-Sleep -Seconds (10 * $attempt)",
" ",
" } catch {",
" Write-DSCLog \"Attempt $attempt failed: $($_.Exception.Message)\" -Level Warning",
" Pop-StackTrace",
" if ($attempt -eq $maxRetries) { throw }",
" Start-Sleep -Seconds (10 * $attempt)",
" }",
" }",
" ",
" $appTimer.Stop()",
" $result.Performance.ApplicationTime = $appTimer.Elapsed.TotalSeconds",
" ",
" # Enhanced Compliance Checking",
" if ([bool]::Parse('{{complianceCheck}}') -and $applied) {",
" $compTimer = [System.Diagnostics.Stopwatch]::StartNew()",
" Write-DSCLog 'Running compliance validation'",
" ",
" $testResult = Test-DscConfiguration -Detailed",
" $compTimer.Stop()",
" $result.Performance.ComplianceTime = $compTimer.Elapsed.TotalSeconds",
" ",
" $result.ComplianceCheck = @{",
" InDesiredState = $testResult.InDesiredState",
" TestDate = [DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss')",
" Duration = $result.Performance.ComplianceTime",
" ResourcesTotal = ($testResult.ResourcesInDesiredState.Count + $testResult.ResourcesNotInDesiredState.Count)",
" ResourcesCompliant = $testResult.ResourcesInDesiredState.Count",
" ResourcesNonCompliant = $testResult.ResourcesNotInDesiredState.Count",
" }",
" ",
" if ($testResult.InDesiredState) {",
" Write-DSCLog 'SUCCESS: All resources in desired state'",
" $result.Status = 'Success'",
" } else {",
" Write-DSCLog \"WARNING: $($testResult.ResourcesNotInDesiredState.Count) resources not compliant\" -Level Warning",
" $result.Status = 'SuccessWithWarnings'",
" }",
" } else {",
" $result.Status = 'Success'",
" }",
" ",
" Pop-StackTrace",
" }",
" ",
" Pop-StackTrace",
"",
"} catch {",
" $result.Status = 'Failed'",
" $result.Error = $_.Exception.Message",
" $result.Messages.Add(\"FAILED: $($_.Exception.Message)\")",
" Write-DSCLog \"DSC operation failed: $($_.Exception.Message)\" -Level Error",
" ",
" # Enhanced error details for troubleshooting",
" $errorDetails = @{",
" Message = $_.Exception.Message",
" Type = $_.Exception.GetType().FullName",
" LineNumber = $_.InvocationInfo.ScriptLineNumber",
" Command = $_.InvocationInfo.Line.Trim()",
" StackTraceType = if ($global:stackTrace) { $global:stackTrace.GetType().FullName } else { 'null' }",
" }",
" Write-DSCLog \"Error details: $($errorDetails | ConvertTo-Json -Compress)\" -Level Debug",
" exit 1",
"",
"} finally {",
" # Performance summary and cleanup",
" $result.EndTime = [DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss')",
" $totalDuration = ([DateTime]::Now - [DateTime]::Parse($result.StartTime)).TotalSeconds",
" $result.TotalDuration = [math]::Round($totalDuration, 2)",
" ",
" Write-DSCLog \"=== EXECUTION SUMMARY ===\"",
" Write-DSCLog \"Status: $($result.Status)\"",
" Write-DSCLog \"Total Duration: $($result.TotalDuration) seconds\"",
" Write-DSCLog \"Modules: $($result.Performance.ModuleInstallTime)s | Download: $($result.Performance.DownloadTime)s | App: $($result.Performance.ApplicationTime)s\" -Level Verbose",
" ",
" # Flush any remaining logs",
" if ($global:LogBuffer -and $global:LogBuffer.Count -gt 0 -and $env:DSCLogFileName) {",
" try { $global:LogBuffer | Out-File -FilePath $env:DSCLogFileName -Append -Encoding UTF8 } catch { }",
" }",
" ",
" # Output final result",
" try {",
" $result | ConvertTo-Json -Depth 5 -Compress | Write-Output",
" } catch {",
" Write-Output \"Status: $($result.Status), Duration: $($result.TotalDuration)s\"",
" }",
" ",
" # Cleanup",
" if (Test-Path $workDir -ErrorAction SilentlyContinue) {",
" Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue",
" }",
" ",
" # Memory cleanup",
" if ($global:LogBuffer -and $global:LogBuffer.GetType().Name -eq 'List`1') { ",
" try { $global:LogBuffer.Clear() } catch { } ",
" }",
" if ($global:stackTrace -and $global:stackTrace.GetType().Name -eq 'Stack`1') { ",
" try { $global:stackTrace.Clear() } catch { } ",
" }",
" [System.GC]::Collect()",
" ",
" Write-DSCLog \"DSC operation completed: $($result.Status)\"",
"}"
]
},
"outputs": [
{
"Name": "ConfigurationStatus",
"Selector": "$",
"Type": "StringMap"
}
]
}
],
"outputs": [
"applyDSCConfiguration.ConfigurationStatus"
]
}
Save this as DSC-Apply-Configuration.json
and create the document:
aws ssm create-document `
--name "DSC-Apply-CIS-Configuration" `
--document-type "Command" `
--content file://DSC-Apply-Configuration.json `
--document-format "JSON"
Creating State Manager Association
Now let's create associations that automatically apply our configurations. In the console, you can do this via Systems Manager > Node Tools > State Manager > Create association.
Throw a name in there if you'd like and then search for the document you just uploaded. We're going to use the test-lightweight.mof
as our guinea pig first and run it once.

Or the same information via CLI:
aws ssm create-association `
--name "DSC_Apply_CIS_Configuration" `
--association-name "TEST-Lightweight-SingleServer-Association"
--targets "Key=InstanceIds,Values=i-YOUR-INSTANCE-ID" `
--parameters '{
"configurationName": ["TestLightweightConfiguration"],
"configurationS3Bucket": ["systems-manager-windows-server-dsc-configurations"],
"configurationS3Key": ["configurations/MOF/test-lightweight.mof"],
"requiredModules": ["Cim;SecurityPolicy;DSC.AuditPolicy;DSC.NetworkingDsc;ComputerManagementDsc"],
"testMode": ["true"],
"complianceCheck": ["true"],
"EnableVerboseLogging": ["false"],
"EnableDebugLogging": ["false"],
"ProxyUri": [""],
"RebootBehavior": ["IfRequired"],
"MaxRetryAttempts": [""]
}' `
--output-location '{
"S3Location": {
"OutputS3BucketName": "systems-manager-windows-server-dsc-configurations",
"OutputS3KeyPrefix": "associations/dsc/"
}
}' `
--compliance-severity "HIGH" `
--max-errors "0" `
--region "us-east-1"
You will be redirected to the Association's page and see it is in a grey Pending state.

Wait a couple minutes and it should change to a green Success on refresh. Once it's green, click on it and go the Execution History and you should see a success as well.

You can drill down to the output, maybe seeing it install some other DSC modules that we haven't covered yet. We also send it to the S3 bucket we've been using for MOFs, so you can check there as well but note the GUID there is your Run Command ID, not your Execution History ID.
If you look at the registry or settings nothing was changed. Why? Because we set the test mode = true flag, let's modify the association again and change that value.
You can just edit via console or send another cli.
aws ssm update-association `
--association-id "YOUR-ASSOCIATION-ID" `
--parameters '{"TestMode": ["false"]}' `
--region "us-east-1"
After the update we once again we wait for Systems Manager to reach out to our instance and apply the configurations. Once it shows Success, let's RDP and check the registry, you should see a HKEY_LOCAL_MACHINE\SOFTWARE\DSCTest
folder with a DSCTestValue set to TestConfiguration now. You can also check the telnet and print spooler settings to verify as well.
Deployment Strategies
Now that you've successfully deployed to a single test instance, let's talk about scaling up safely. The jump from one server to many is where things get interesting (and potentially dangerous).
Start with Tags
The key to safe deployments is using EC2 tags to control your rollout. If you haven't already when we made the template, tag your instances based on their role and environment:
# Tag your test instances
aws ec2 create-tags --resources i-YOUR-INSTANCE-ID, i-YOU-INSTANCE-ID2 --tags Key=Environment,Value=Test
# Tag instance by role
aws ec2 create-tags --resources i-YOUR-INSTANCE-ID --tags Key=Environment,Value=Test Key=DSCRole,Value=MemberServer
# Tag production servers by criticality
aws ec2 create-tags --resources i-PROD-INSTANCE-1 --tags Key=Environment,Value=Production Key=DeploymentGroup,Value=Canary
aws ec2 create-tags --resources i-PROD-INSTANCE-2,i-PROD-INSTANCE-3 --tags Key=Environment,Value=Production Key=DeploymentGroup,Value=Wave1
The Safe Scaling Approach
Here's how to go from one server to many without breaking everything:
1. Expand to Your Test Environment
First, deploy to all test servers you just tagged to catch any server-specific issues:
# Create association for test environment
aws ssm create-association `
--name "DSC-Apply-CIS-Configuration" `
--association-name "DSC-CIS-Test-Association" `
--targets "Key=tag:Environment,Values=Test" `
--parameters '{
"configurationName": ["DSC-CIS-TEST"],
"configurationS3Bucket": ["systems-manager-windows-server-dsc-configurations"],
"configurationS3Key": ["configurations/MemberServer/localhost.mof"],
"requiredModules": ["Cim;SecurityPolicy;DSC.AuditPolicy;DSC.NetworkingDsc;ComputerManagementDsc"],
"testMode": ["false"],
"complianceCheck": ["true"],
"EnableVerboseLogging": ["false"],
"EnableDebugLogging": ["false"],
"ProxyUri": [""],
"RebootBehavior": ["IfRequired"],
"MaxRetryAttempts": ["1"]
}' `
--output-location '{
"S3Location": {
"OutputS3BucketName": "systems-manager-windows-server-dsc-configurations",
"OutputS3KeyPrefix": "associations/dsc/test"
}
}' `
--schedule-expression "rate(12 hours)" `
--max-concurrency "2" `
--max-errors "1" `
--region "us-east-1"
Key settings explained:
max-concurrency "2"
- Only 2 servers at a timemax-errors "1"
- Stop if any server failsschedule-expression "rate(12 hours)"
- Re-apply twice daily
2. Production Canary Deployment
Pick 1-2 production servers as your "canaries" - these brave servers get changes first:
# Deploy to canary servers only
aws ssm create-association `
--name "DSC-Apply-CIS-Configuration" `
--association-name "DSC-CIS-Canary-Association" `
--targets "Key=tag:Environment,Values=Canary" `
--parameters '{
"configurationName": ["DSC-CIS-CANARY"],
"configurationS3Bucket": ["systems-manager-windows-server-dsc-configurations"],
"configurationS3Key": ["configurations/MemberServer/localhost.mof"],
"requiredModules": ["Cim;SecurityPolicy;DSC.AuditPolicy;DSC.NetworkingDsc;ComputerManagementDsc"],
"testMode": ["false"],
"complianceCheck": ["true"],
"EnableVerboseLogging": ["false"],
"EnableDebugLogging": ["false"],
"ProxyUri": [""],
"RebootBehavior": ["IfRequired"],
"MaxRetryAttempts": ["1"]
}' `
--output-location '{
"S3Location": {
"OutputS3BucketName": "systems-manager-windows-server-dsc-configurations",
"OutputS3KeyPrefix": "associations/dsc/canary"
}
}' `
--schedule-expression "rate(24 hours)" `
--region "us-east-1"
Monitor for 24-48 hours before proceeding. Check:
- Performance metrics (CPU, memory)
- Event logs for errors
- Application functionality
- User complaints
3. Gradual Production Rollout
If your canaries survive, gradually increase the deployment scope:
# Phase 1: 10% of production
aws ssm create-association `
--name "DSC-Apply-CIS-Configuration" `
--association-name "DSC-CIS-Prod-Association"
--targets "Key=tag:Environment,Values=Prod" `
--parameters '{
"configurationName": ["DSC-CIS-PROD"],
"configurationS3Bucket": ["systems-manager-windows-server-dsc-configurations"],
"configurationS3Key": ["configurations/MemberServer/localhost.mof"],
"requiredModules": ["Cim;SecurityPolicy;DSC.AuditPolicy;DSC.NetworkingDsc;ComputerManagementDsc"],
"testMode": ["false"],
"complianceCheck": ["true"],
"EnableVerboseLogging": ["false"],
"EnableDebugLogging": ["false"],
"ProxyUri": [""],
"RebootBehavior": ["IfRequired"],
"MaxRetryAttempts": ["1"]
}' `
--output-location '{
"S3Location": {
"OutputS3BucketName": "systems-manager-windows-server-dsc-configurations",
"OutputS3KeyPrefix": "associations/dsc/prod"
}
}' `
--max-concurrency "10%" `
--max-errors "5" ``
--region "us-east-1"
# After validation, Phase 2: 50% concurrency
aws ssm update-association `
--association-id "YOUR-ASSOCIATION-ID" `
--max-concurrency "50%"
# Finally: All servers (but still with limits)
aws ssm update-association `
--association-id "YOUR-ASSOCIATION-ID" `
--max-concurrency "20" `
--max-errors "10"
Understanding Concurrency Settings
- Percentage:
"10%"
- Deploys to 10% of targeted instances at once - Fixed number:
"20"
- Deploys to exactly 20 instances at once - Which to use: Percentages for small fleets, fixed numbers for large fleets (you don't want 10% of 1000 servers = 100 simultaneous deployments)
Quick Rollback Plan
If something goes wrong, here's your emergency brake:
# Option 1: Stop the association immediately (prevents future runs)
aws ssm delete-association --association-id "YOUR-ASSOCIATION-ID"
# Option 2: Disable the association (keeps it but stops execution)
aws ssm update-association `
--association-id "YOUR-ASSOCIATION-ID" `
--schedule-expression "rate(999 days)" `
--region "us-east-1"
# Option 3: Revert to test mode (safer than deletion)
aws ssm update-association `
--association-id "YOUR-ASSOCIATION-ID" `
--parameters '{
"ConfigurationName": ["YOUR-CONFIGURATION-NAME"],
"configurationS3Bucket": ["systems-manager-windows-server-dsc-configurations"],
"configurationS3Key": ["configurations/MemberServer/localhost.mof"],
"TestMode": ["true"],
"ComplianceCheck": ["false"]
}' `
--region "us-east-1"
Deployment Checklist
Before each phase:
- [ ] Review compliance reports from previous phase
- [ ] Check performance metrics
- [ ] Verify no critical alerts fired
- [ ] Confirm rollback plan is ready
- [ ] Document any new exclusions needed
For now, this manual phased approach works well for fleets up to a few hundred servers. In Part 4, we'll automate this entire process with PowerShell functions that handle the monitoring and phase progression automatically.
Monitoring and Compliance
Deploying is only half the battle. You might have been wondering how you're supposed to check for metrics. Now we need visibility into what's actually happening.
Real-time Compliance Monitoring
In the console, you can view compliance data under Systems Manager > Node Tools > Compliance.
Or if you're like me and prefer scripts, let's build a compliance report using PowerShell.
<#
.SYNOPSIS
Generates comprehensive AWS Systems Manager compliance reports for managed instances.
.DESCRIPTION
This script retrieves and analyzes compliance status across AWS Systems Manager managed Windows instances.
It provides detailed reporting on all compliance items (patches, associations, custom compliance),
execution history, and generates exportable reports for trending and analysis.
The script uses AWS CLI for all API interactions.
.PARAMETER InstanceIds
Optional array of specific EC2 instance IDs to check. If not provided, all online Windows managed instances will be checked.
.PARAMETER DetailedReport
Switch parameter to include detailed information about non-compliant items in the console output.
.PARAMETER TimeoutSeconds
Timeout value for AWS CLI operations. Default is 30 seconds.
.NOTES
File Name : Get-SSMCompliance.ps1
Author : Jeffrey Stuhr
Prerequisites : AWS CLI must be installed and configured with appropriate IAM permissions
Required IAM Permissions:
- ssm:DescribeInstanceInformation
- ssm:ListComplianceItems
- ssm:ListCommandInvocations
- sts:GetCallerIdentity
Blog Reference: This script supports Systems Manager compliance monitoring as discussed at:
https://www.techbyjeff.net/
.LINK
https://www.techbyjeff.net/
.EXAMPLE
.\Get-SSMCompliance.ps1
Generates a compliance report for all online Windows managed instances with basic output.
.EXAMPLE
.\Get-SSMCompliance.ps1 -DetailedReport
Generates a compliance report with detailed non-compliant items displayed in the console.
.EXAMPLE
.\Get-SSMCompliance.ps1 -InstanceIds @("i-0123456789abcdef0", "i-0987654321fedcba0") -DetailedReport
Generates a detailed compliance report for specific instances only.
#>
# Get comprehensive compliance status
function Get-SSMComplianceReport {
<#
.SYNOPSIS
Core function that retrieves Systems Manager compliance data from AWS.
.DESCRIPTION
This function queries AWS Systems Manager for all compliance information across managed instances,
including patch compliance, association compliance, and custom compliance items.
Processes the data and returns a structured report object containing compliance metrics.
#>
param(
[Parameter(Mandatory=$false, HelpMessage="Array of EC2 instance IDs to check for compliance")]
[string[]]$InstanceIds = @(),
[Parameter(Mandatory=$false, HelpMessage="Include detailed non-compliant items in console output")]
[switch]$DetailedReport,
[Parameter(Mandatory=$false, HelpMessage="Timeout for AWS CLI operations in seconds")]
[int]$TimeoutSeconds = 30
)
# Verify AWS CLI availability and authentication
Write-Host "Verifying AWS CLI prerequisites..." -ForegroundColor Green
# Check if AWS CLI is available in the system PATH
if (-not (Get-Command aws -ErrorAction SilentlyContinue)) {
throw "AWS CLI is not installed or not in PATH. Please install AWS CLI first."
}
# Test AWS CLI connectivity and authentication
Write-Host "Testing AWS CLI connectivity..." -ForegroundColor Green
try {
# Attempt to get caller identity to verify authentication
$testResult = aws sts get-caller-identity --output json 2>$null
if ($LASTEXITCODE -ne 0) {
throw "AWS CLI authentication failed. Please run 'aws configure' first."
}
Write-Host "AWS CLI connectivity confirmed." -ForegroundColor Green
}
catch {
throw "AWS CLI test failed: $_"
}
# Retrieve target instances if none specified
if ($InstanceIds.Count -eq 0) {
Write-Host "Retrieving managed instances..." -ForegroundColor Green
# Query for all online Windows instances managed by Systems Manager
# Uses JMESPath query to filter for online Windows instances only
$instancesJson = aws ssm describe-instance-information --query "InstanceInformationList[?PingStatus=='Online' && PlatformType=='Windows'].InstanceId" --output json 2>$null
if ($LASTEXITCODE -ne 0) {
throw "Failed to retrieve instance information from AWS"
}
# Parse JSON response and convert to PowerShell array
$InstanceIds = $instancesJson | ConvertFrom-Json
Write-Host "Found $($InstanceIds.Count) managed instances." -ForegroundColor Green
}
# Initialize data collection array
$complianceData = @()
$instanceCount = 0
# Process each instance for compliance data
foreach ($instanceId in $InstanceIds) {
$instanceCount++
# Update progress bar with current status
Write-Progress -Activity "Checking compliance" -Status "$instanceId ($instanceCount of $($InstanceIds.Count))" -PercentComplete (($instanceCount / $InstanceIds.Count) * 100)
Write-Host "Processing instance: $instanceId" -ForegroundColor Cyan
try {
# Retrieve instance metadata
Write-Host " Getting instance details..." -ForegroundColor Gray
# Query specific instance information using instance ID filter
$instanceJson = aws ssm describe-instance-information --instance-information-filter-list "key=InstanceIds,valueSet=$instanceId" --output json 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Warning "Failed to get instance information for $instanceId"
continue
}
# Parse and validate instance response
$instanceResult = $instanceJson | ConvertFrom-Json
if (-not $instanceResult.InstanceInformationList -or $instanceResult.InstanceInformationList.Count -eq 0) {
Write-Warning "No instance information found for $instanceId"
continue
}
$instance = $instanceResult.InstanceInformationList[0]
# Retrieve compliance items for this instance
Write-Host " Getting compliance items..." -ForegroundColor Gray
# Query all compliance items for the managed instance
# This includes patch compliance, association compliance, and any custom compliance items
$complianceJson = aws ssm list-compliance-items --resource-id $instanceId --resource-type "ManagedInstance" --output json 2>$null
$compliance = @()
if ($LASTEXITCODE -eq 0) {
$complianceResult = $complianceJson | ConvertFrom-Json
if ($complianceResult.ComplianceItems) {
$compliance = $complianceResult.ComplianceItems
}
}
# Retrieve command execution history to find recent document executions
Write-Host " Getting command history..." -ForegroundColor Gray
# Query recent command invocations to find DSC or other document executions
$commandsJson = aws ssm list-command-invocations --instance-id $instanceId --max-items 5 --output json 2>$null
$lastExecution = $null
if ($LASTEXITCODE -eq 0) {
$commandsResult = $commandsJson | ConvertFrom-Json
if ($commandsResult.CommandInvocations) {
# Get the most recent command execution (prioritize DSC if available)
$dscExecution = $commandsResult.CommandInvocations | Where-Object {$_.DocumentName -like "*DSC*"} | Select-Object -First 1
$lastExecution = if ($dscExecution) { $dscExecution } else { $commandsResult.CommandInvocations | Select-Object -First 1 }
}
}
# Build compliance data object for this instance
$complianceData += [PSCustomObject]@{
InstanceId = $instanceId
Name = if ($instance.ComputerName) { $instance.ComputerName } else { "Unknown" }
LastExecution = if ($lastExecution) { $lastExecution.RequestedDateTime } else { $null }
Status = if ($lastExecution) { $lastExecution.Status } else { "Unknown" }
CompliantItems = ($compliance | Where-Object {$_.Status -eq "COMPLIANT"}).Count
NonCompliantItems = ($compliance | Where-Object {$_.Status -eq "NON_COMPLIANT"}).Count
CompliancePercentage = if ($compliance.Count -gt 0) {
# Calculate compliance percentage rounded to 2 decimal places
[math]::Round((($compliance | Where-Object {$_.Status -eq "COMPLIANT"}).Count / $compliance.Count) * 100, 2)
} else { 0 }
}
# Display detailed non-compliant items if requested
if ($DetailedReport -and ($compliance | Where-Object {$_.Status -eq "NON_COMPLIANT"})) {
Write-Host "`nNon-compliant items for $($instance.ComputerName):" -ForegroundColor Yellow
$compliance | Where-Object {$_.Status -eq "NON_COMPLIANT"} |
Select-Object Title, Severity |
Format-Table -AutoSize
}
}
catch {
# Log any errors encountered during instance processing
Write-Warning "Error processing instance $instanceId : $_"
continue
}
}
# Clear progress bar
Write-Progress -Activity "Checking compliance" -Completed
return $complianceData
}
# Main execution block
Write-Host "Starting Systems Manager Compliance Report..." -ForegroundColor Green
# Generate the compliance report
$report = Get-SSMComplianceReport -DetailedReport
# Display results in formatted table
$report | Format-Table -AutoSize
# Export results to CSV file with timestamp
$csvPath = ".\SSM-Compliance-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv"
$report | Export-Csv -Path $csvPath -NoTypeInformation
Write-Host "Report exported to: $csvPath" -ForegroundColor Green
Creating CloudWatch Dashboard
Let's create a basic dashboard to visualize compliance:
{
"widgets": [
{
"type": "metric",
"x": 0,
"y": 0,
"width": 12,
"height": 6,
"properties": {
"metrics": [
[ "AWS/SSM-RunCommand", "CommandsSucceeded", { "stat": "Sum" } ],
[ ".", "CommandsFailed", { "stat": "Sum" } ]
],
"period": 300,
"region": "us-east-1",
"title": "DSC Deployment Status"
}
},
{
"type": "log",
"x": 12,
"y": 0,
"width": 12,
"height": 6,
"properties": {
"query": "SOURCE '/aws/systemsmanager/ssm-errors' | fields @timestamp, @message\n| filter @message like /ERROR/ or @message like /failed/\n| parse @message /(?<instance>i-[a-z0-9]+)/\n| parse @message /ERROR\\s+(?<error_summary>.{1,100})/\n| sort @timestamp desc\n| limit 10",
"region": "us-east-1",
"title": "SSM Agent Errors",
"view": "table"
}
},
{
"type": "log",
"x": 0,
"y": 6,
"width": 12,
"height": 6,
"properties": {
"query": "SOURCE '/aws/systemsmanager/dsc-files' | fields @timestamp, @message\n| parse @message /\\[(?<level>\\w+)\\]/\n| parse @message /Server Role: (?<role>\\w+)/\n| parse @message /(?<summary>[^\\n]{1,80})/\n| sort @timestamp desc\n| limit 15",
"region": "us-east-1",
"title": "DSC Execution Details",
"view": "table"
}
},
{
"type": "log",
"x": 12,
"y": 6,
"width": 12,
"height": 6,
"properties": {
"query": "SOURCE '/aws/systemsmanager/dsc-files' | fields @timestamp, @message, @logStream\n| filter @message like /ERROR/ or @message like /failed/ or @message like /Starting/ or @message like /Completed/\n| parse @logStream /(?<instance_id>i-[a-z0-9]+)/\n| sort @timestamp desc\n| limit 10",
"region": "us-east-1",
"title": "Critical Events by Instance (DSC Files)",
"view": "table"
}
},
{
"type": "log",
"x": 0,
"y": 12,
"width": 12,
"height": 6,
"properties": {
"query": "SOURCE '/aws/systemsmanager/ssm-errors' | fields @timestamp, @message, @logStream\n| filter @message like /ERROR/ or @message like /failed/\n| parse @logStream /(?<instance_id>i-[a-z0-9]+)/\n| sort @timestamp desc\n| limit 10",
"region": "us-east-1",
"title": "Critical Events by Instance (SSM Errors)",
"view": "table"
}
}
]
}
Save the above as dashboard.json
and then we'll create the dashboard:
aws cloudwatch put-dashboard `
--dashboard-name "DSC-CIS-Compliance" `
--dashboard-body file://dashboard.json `
--region "us-east-1"

Setting Up Alerts
Don't wait for someone to check the dashboard - get notified when things go wrong:
# Setup-DSC-Monitoring.ps1
# Run this ONCE to set up CloudWatch monitoring for your DSC configurations
Write-Host "Setting up DSC CloudWatch monitoring..." -ForegroundColor Cyan
# Step 1: Create SNS topic for alerts
Write-Host "Creating SNS topic for alerts..." -ForegroundColor Yellow
try {
$topic = aws sns create-topic --name "DSC-Compliance-Alerts" --query 'TopicArn' --output text
Write-Host "✓ SNS topic created: $topic" -ForegroundColor Green
# Subscribe your email (CHANGE THIS EMAIL!)
$email = "your-email@company.com" # <-- CHANGE THIS TO YOUR EMAIL
aws sns subscribe `
--topic-arn $topic `
--protocol email `
--notification-endpoint $email
Write-Host "✓ Email subscription added for: $email" -ForegroundColor Green
Write-Host " Check your email and confirm the subscription!" -ForegroundColor Yellow
} catch {
Write-Host "✗ Failed to create SNS topic" -ForegroundColor Red
Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red
return
}
# Step 2: Create metric filters for log analysis
Write-Host "Creating CloudWatch log metric filters..." -ForegroundColor Yellow
try {
# Filter for successful DSC executions
aws logs put-metric-filter `
--log-group-name "/aws/systemsmanager/dsc-files" `
--filter-name "DSC-Success-Count" `
--filter-pattern "[timestamp, level=INFO, message*completed*successfully*]" `
--metric-transformations `
metricName=DSC_Success_Count,metricNamespace=CWAgent,metricValue=1
# Filter for total DSC executions
aws logs put-metric-filter `
--log-group-name "/aws/systemsmanager/dsc-files" `
--filter-name "DSC-Total-Count" `
--filter-pattern "[timestamp, level=INFO, message*Starting*DSC*Configuration*]" `
--metric-transformations `
metricName=DSC_Total_Count,metricNamespace=CWAgent,metricValue=1
# Filter for DSC errors
aws logs put-metric-filter `
--log-group-name "/aws/systemsmanager/dsc-files" `
--filter-name "DSC-Error-Count" `
--filter-pattern "[timestamp, level=ERROR, ...]" `
--metric-transformations `
metricName=DSC_Error_Count,metricNamespace=CWAgent,metricValue=1
# Filter for SSM errors
aws logs put-metric-filter `
--log-group-name "/aws/systemsmanager/ssm-errors" `
--filter-name "SSM-Error-Count" `
--filter-pattern "[timestamp, level=ERROR, ...]" `
--metric-transformations `
metricName=SSM_Error_Count,metricNamespace=CWAgent,metricValue=1
Write-Host "✓ All metric filters created successfully" -ForegroundColor Green
} catch {
Write-Host "✗ Failed to create metric filters" -ForegroundColor Red
Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red
}
# Step 3: Create CloudWatch alarms
Write-Host "Creating CloudWatch alarms..." -ForegroundColor Yellow
try {
# Alarm for failed deployments (using existing SSM metrics)
aws cloudwatch put-metric-alarm `
--alarm-name "DSC-Deployment-Failures" `
--alarm-description "Alert on DSC deployment failures" `
--metric-name "CommandsFailed" `
--namespace "AWS/SSM-RunCommand" `
--statistic Sum `
--period 300 `
--evaluation-periods 1 `
--threshold 5 `
--comparison-operator GreaterThanThreshold `
--alarm-actions $topic
# Alarm for DSC errors in logs
aws cloudwatch put-metric-alarm `
--alarm-name "DSC-Critical-Errors" `
--alarm-description "Alert on ERROR level messages in DSC logs" `
--metric-name "DSC_Error_Count" `
--namespace "CWAgent" `
--statistic Sum `
--period 300 `
--evaluation-periods 1 `
--threshold 1 `
--comparison-operator GreaterThanOrEqualToThreshold `
--alarm-actions $topic
# Alarm for SSM errors in logs
aws cloudwatch put-metric-alarm `
--alarm-name "SSM-Critical-Errors" `
--alarm-description "Alert on ERROR level messages in SSM logs" `
--metric-name "SSM_Error_Count" `
--namespace "CWAgent" `
--statistic Sum `
--period 300 `
--evaluation-periods 1 `
--threshold 1 `
--comparison-operator GreaterThanOrEqualToThreshold `
--alarm-actions $topic
Write-Host "✓ All CloudWatch alarms created successfully" -ForegroundColor Green
} catch {
Write-Host "✗ Failed to create CloudWatch alarms" -ForegroundColor Red
Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red
}
Write-Host ""
Write-Host "=== DSC Monitoring Setup Complete! ===" -ForegroundColor Cyan
Write-Host "✓ SNS topic created for alerts" -ForegroundColor Green
Write-Host "✓ Log metric filters created" -ForegroundColor Green
Write-Host "✓ CloudWatch alarms configured" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:" -ForegroundColor White
Write-Host "1. Check your email and confirm the SNS subscription" -ForegroundColor Yellow
Write-Host "2. Wait for some DSC executions to generate metrics" -ForegroundColor Yellow
Write-Host "3. Check the CloudWatch console to verify metrics are appearing" -ForegroundColor Yellow
Write-Host "4. Test alerts by triggering a failure (optional)" -ForegroundColor Yellow
The metric filters will watch your log files and count:
- How many times DSC starts (total executions)
- How many times DSC completes successfully
- How many ERROR messages appear
Then your alarms can use these counts to alert you when things go wrong.
What Gets Created (Once for Your Entire AWS Account):
- SNS Topic - One topic that receives alerts from all servers
- Log Metric Filters - These watch the CloudWatch log groups that ALL your servers write to
- CloudWatch Alarms - These monitor metrics across ALL servers
How It Works:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Server 1 │ │ Server 2 │ │ Server 3 │
│ (Web Server) │ │ (Domain Ctrl) │ │ (Member Srv) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
│
▼
┌─────────────────────────┐
│ CloudWatch Log Groups │
│ - /aws/systemsmanager/ │
│ dsc-files │
│ - /aws/systemsmanager/ │
│ ssm-errors │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Metric Filters │ ← Created ONCE
│ (Count errors/success)│
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ CloudWatch Alarms │ ← Created ONCE
│ (Alert on thresholds) │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ SNS Topic │ ← Created ONCE
│ (Email notifications) │
└─────────────────────────┘
Custom Metrics for Deep Insights
For more detailed monitoring, push custom metrics:
# Complete DSC Monitoring Setup using JSON file method
Write-Host "Setting up complete DSC monitoring using JSON file approach..." -ForegroundColor Cyan
# Full monitoring script - no AWS CLI needed on instances, just detailed logging
$fullMonitoringScript = @'
function Write-DSCLog {
param([string]$Message, [string]$Level = "INFO")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
$logEntry = "[$timestamp] [$Level] $Message"
Write-Host $logEntry
# Write to log file for CloudWatch Logs
$logFile = "C:\Logs\DSC\dsc-monitoring.log"
$logDir = Split-Path $logFile
if (!(Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force }
Add-Content -Path $logFile -Value $logEntry -ErrorAction SilentlyContinue
}
try {
# Get instance info
try {
$instanceId = (Invoke-RestMethod -Uri "http://169.254.169.254/latest/meta-data/instance-id" -TimeoutSec 3)
} catch {
$instanceId = $env:COMPUTERNAME
Write-DSCLog "Using computer name as instance ID: $instanceId" -Level "WARN"
}
Write-DSCLog "=== Starting DSC Compliance Check ===" -Level "INFO"
Write-DSCLog "Instance: $instanceId" -Level "INFO"
Write-DSCLog "Timestamp: $(Get-Date)" -Level "INFO"
# Get DSC status
Write-DSCLog "Running Test-DscConfiguration..." -Level "INFO"
$dscStatus = Test-DscConfiguration -Detailed
# Calculate compliance
$totalResources = $dscStatus.ResourcesInDesiredState.Count + $dscStatus.ResourcesNotInDesiredState.Count
$compliantResources = $dscStatus.ResourcesInDesiredState.Count
$nonCompliantResources = $dscStatus.ResourcesNotInDesiredState.Count
$compliancePercentage = if ($totalResources -gt 0) {
[math]::Round(($compliantResources / $totalResources) * 100, 2)
} else { 0 }
Write-DSCLog "=== DSC Compliance Results ===" -Level "INFO"
Write-DSCLog "Total Resources: $totalResources" -Level "INFO"
Write-DSCLog "Compliant Resources: $compliantResources" -Level "INFO"
Write-DSCLog "Non-Compliant Resources: $nonCompliantResources" -Level "INFO"
Write-DSCLog "Compliance Percentage: $compliancePercentage%" -Level "INFO"
# Log metric in parseable format for dashboard
Write-DSCLog "METRIC|DSC_Compliance_Percentage|$compliancePercentage|Percent|InstanceId=$instanceId" -Level "INFO"
Write-DSCLog "METRIC|DSC_Total_Resources|$totalResources|Count|InstanceId=$instanceId" -Level "INFO"
Write-DSCLog "METRIC|DSC_NonCompliant_Resources|$nonCompliantResources|Count|InstanceId=$instanceId" -Level "INFO"
# Log details of non-compliant resources
if ($nonCompliantResources -gt 0) {
Write-DSCLog "=== Non-Compliant Resource Details ===" -Level "WARN"
$dscStatus.ResourcesNotInDesiredState | ForEach-Object {
Write-DSCLog "Non-Compliant: $($_.ResourceId) - $($_.InDesiredState)" -Level "WARN"
}
} else {
Write-DSCLog "All resources are compliant!" -Level "INFO"
}
Write-DSCLog "=== DSC Compliance Check Complete ===" -Level "INFO"
} catch {
Write-DSCLog "=== DSC Compliance Check Failed ===" -Level "ERROR"
Write-DSCLog "Error: $($_.Exception.Message)" -Level "ERROR"
Write-DSCLog "METRIC|DSC_Check_Errors|1|Count|InstanceId=$instanceId" -Level "ERROR"
}
'@
# Create JSON file for the monitoring command
$monitoringJsonFile = "dsc-monitoring-command.json"
$monitoringCommand = @{
DocumentName = "AWS-RunPowerShellScript"
Targets = @(
@{
Key = "tag:Environment"
Values = @("test")
}
)
Parameters = @{
commands = @($fullMonitoringScript)
}
} | ConvertTo-Json -Depth 4
$monitoringCommand | Out-File -FilePath $monitoringJsonFile -Encoding UTF8
# Test the full monitoring script
Write-Host "Testing full monitoring script..." -ForegroundColor Yellow
$testCommandId = aws ssm send-command --cli-input-json "file://$monitoringJsonFile" --query 'Command.CommandId' --output text
Write-Host "✓ Test command sent: $testCommandId" -ForegroundColor Green
# Wait for execution
Write-Host "Waiting 30 seconds for execution..." -ForegroundColor Yellow
Start-Sleep -Seconds 30
# Check results
Write-Host "Getting results..." -ForegroundColor Yellow
$output = aws ssm get-command-invocation --command-id $testCommandId --instance-id "i-004ff505a11d64441" --query 'StandardOutputContent' --output text
Write-Host ""
Write-Host "=== Monitoring Script Output ===" -ForegroundColor Cyan
Write-Host $output -ForegroundColor White
if ($output -like "*DSC Compliance Check Complete*") {
Write-Host ""
Write-Host "✓ Full monitoring script works! Setting up maintenance window..." -ForegroundColor Green
# Create maintenance window
Write-Host "Creating maintenance window..." -ForegroundColor Yellow
$windowId = aws ssm create-maintenance-window `
--name "DSC-Compliance-Monitoring" `
--description "Hourly DSC compliance monitoring with detailed logging" `
--duration 1 `
--cutoff 0 `
--schedule "rate(1 hour)" `
--allow-unassociated-targets `
--query 'WindowId' `
--output text
Write-Host "✓ Maintenance window created: $windowId" -ForegroundColor Green
# Register targets
Write-Host "Registering targets..." -ForegroundColor Yellow
$targetId = aws ssm register-target-with-maintenance-window `
--window-id $windowId `
--resource-type "INSTANCE" `
--targets "Key=tag:Environment,Values=test" `
--query 'WindowTargetId' `
--output text
Write-Host "✓ Targets registered: $targetId" -ForegroundColor Green
# Create task parameters file
$taskParamsFile = "dsc-task-params.json"
$taskParams = @{
RunCommand = @{
Parameters = @{
commands = @($fullMonitoringScript)
}
}
} | ConvertTo-Json -Depth 4
$taskParams | Out-File -FilePath $taskParamsFile -Encoding UTF8
# Register the task
Write-Host "Registering monitoring task..." -ForegroundColor Yellow
$taskId = aws ssm register-task-with-maintenance-window `
--window-id $windowId `
--targets "Key=WindowTargetIds,Values=$targetId" `
--task-type "RUN_COMMAND" `
--task-arn "AWS-RunPowerShellScript" `
--max-concurrency "5" `
--max-errors "1" `
--task-invocation-parameters "file://$taskParamsFile" `
--query 'WindowTaskId' `
--output text
Write-Host "✓ Task registered: $taskId" -ForegroundColor Green
# Clean up temp files
Remove-Item $monitoringJsonFile -ErrorAction SilentlyContinue
Remove-Item $taskParamsFile -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "=== DSC Monitoring Setup Complete! ===" -ForegroundColor Cyan
Write-Host "✓ Maintenance Window: $windowId" -ForegroundColor Green
Write-Host "✓ Target ID: $targetId" -ForegroundColor Green
Write-Host "✓ Task ID: $taskId" -ForegroundColor Green
Write-Host "✓ Schedule: Every hour automatically" -ForegroundColor Green
Write-Host ""
Write-Host "Your CloudWatch dashboard will now show:" -ForegroundColor White
Write-Host "• Detailed DSC compliance logs in /aws/systemsmanager log groups" -ForegroundColor Gray
Write-Host "• Structured METRIC entries you can parse for dashboard widgets" -ForegroundColor Gray
Write-Host "• Resource-level compliance details" -ForegroundColor Gray
Write-Host "• Hourly compliance trending data" -ForegroundColor Gray
} else {
Write-Host "✗ Monitoring script had issues. Output:" -ForegroundColor Red
Write-Host $output -ForegroundColor Red
}
Write-Host ""
Write-Host "=== Monitor Your Setup ===" -ForegroundColor Yellow
Write-Host "Check maintenance window executions:" -ForegroundColor White
Write-Host "aws ssm describe-maintenance-window-executions --window-id $windowId" -ForegroundColor Gray
Write-Host ""
Write-Host "View logs in CloudWatch Console:" -ForegroundColor White
Write-Host "CloudWatch > Logs > /aws/systemsmanager/* log groups" -ForegroundColor Gray
Step-by-Step Breakdown
Step 1: You run this ONCE from your computer
- Creates the PowerShell script as a variable
- Sends it to AWS Systems Manager
- Sets up an hourly schedule
Step 2: Systems Manager takes over
- Looks for all VMs with the tag
Environment=test
- Automatically runs the script on each VM every hour
- Each VM executes the compliance check independently
Step 3: Each VM does the work
- Runs
Test-DscConfiguration -Detailed
on itself - Calculates its own compliance percentage
- Sends its own metrics to CloudWatch
You can verify the output of this script with the Maintenance Windows: mw-
that is shown and correlating with Maintenance Windows in the console.
Scaling Challenges and Solutions
As you scale beyond a handful of servers, new challenges emerge. Let's tackle them head-on.
Performance Impact Analysis
DSC can be CPU-intensive, especially during initial configuration. Here's how to measure and manage the impact:
# Aggregate DSC Performance Results at Scale
Write-Host "Aggregating DSC performance results from multiple instances..." -ForegroundColor Cyan
# Function to parse text-based performance output
function Parse-TextPerformanceData {
param(
[string]$Output,
[string]$InstanceId
)
# Extract key metrics from text output
$duration = if ($Output -match "Operation Duration: ([\d.]+) seconds") { [decimal]$matches[1] } else { $null }
$cpuBaseline = if ($Output -match "Baseline CPU: ([\d.]+)%") { [decimal]$matches[1] } else { $null }
$cpuPeak = if ($Output -match "CPU Impact: [\d.]+% -> ([\d.]+)%") { [decimal]$matches[1] } else { $null }
$cpuChange = if ($Output -match "Change: \+([\d.]+)%") { [decimal]$matches[1] } else { $null }
$memoryUsed = if ($Output -match "Memory Impact: Used ([\d.]+) MB") { [decimal]$matches[1] } else { $null }
$totalResources = if ($Output -match "DSC Resources: (\d+) total") { [int]$matches[1] } else { $null }
$complianceRate = if ($Output -match "Compliance: ([\d.]+)%") { [decimal]$matches[1] } else { $null }
$resourcesPerSec = if ($Output -match "\(([\d.]+) resources/second\)") { [decimal]$matches[1] } else { $null }
$baselineMemory = if ($Output -match "Baseline Free Memory: ([\d.]+) MB") { [decimal]$matches[1] } else { $null }
return [PSCustomObject]@{
InstanceId = $InstanceId
OperationType = "Compliance Check"
Duration = $duration
BaselineCPU = $cpuBaseline
PeakCPU = $cpuPeak
CPUImpact = $cpuChange
BaselineMemoryMB = $baselineMemory
MemoryUsedMB = $memoryUsed
TotalResources = $totalResources
ComplianceRate = $complianceRate
ResourcesPerSecond = $resourcesPerSec
Status = "Success"
Timestamp = Get-Date
}
}
# Function to collect results from all instances
function Get-AggregatedDSCPerformance {
param(
[string]$CommandId,
[string]$TagKey = "Environment",
[string]$TagValue = "test"
)
Write-Host "Getting all instances with tag $TagKey=$TagValue..." -ForegroundColor Yellow
# Get all instance IDs with the specified tag
$instanceIds = aws ec2 describe-instances `
--filters "Name=tag:$TagKey,Values=$TagValue" "Name=instance-state-name,Values=running" `
--query "Reservations[].Instances[].InstanceId" `
--output text
if (!$instanceIds) {
Write-Host "No instances found with tag $TagKey=$TagValue" -ForegroundColor Red
return
}
$instanceList = $instanceIds -split "`s+"
Write-Host "Found $($instanceList.Count) instances: $($instanceList -join ', ')" -ForegroundColor Green
# Collect results from each instance
$allResults = @()
foreach ($instanceId in $instanceList) {
Write-Host "Getting results from instance: $instanceId" -ForegroundColor Yellow
try {
# Get command invocation status
$invocation = aws ssm get-command-invocation `
--command-id $CommandId `
--instance-id $instanceId `
--output json | ConvertFrom-Json
if ($invocation.Status -eq "Success") {
Write-Host "✓ $instanceId - Command completed successfully" -ForegroundColor Green
# Extract performance data from text output (fallback parsing)
$output = $invocation.StandardOutputContent
# Try to parse JSON performance data first
if ($output -match '\{.*"InstanceId".*\}') {
try {
$perfData = ($matches[0] | ConvertFrom-Json)
$allResults += $perfData
Write-Host " Parsed JSON performance data successfully" -ForegroundColor Gray
} catch {
Write-Host " JSON parse failed, using text parsing" -ForegroundColor Yellow
$perfData = Parse-TextPerformanceData -Output $output -InstanceId $instanceId
$allResults += $perfData
}
} else {
Write-Host " No JSON found, parsing text output" -ForegroundColor Gray
$perfData = Parse-TextPerformanceData -Output $output -InstanceId $instanceId
$allResults += $perfData
}
} elseif ($invocation.Status -eq "InProgress") {
Write-Host "⏳ $instanceId - Command still running" -ForegroundColor Yellow
$allResults += [PSCustomObject]@{
InstanceId = $instanceId
Status = "InProgress"
}
} else {
Write-Host "✗ $instanceId - Command failed: $($invocation.Status)" -ForegroundColor Red
$allResults += [PSCustomObject]@{
InstanceId = $instanceId
Status = $invocation.Status
ErrorOutput = $invocation.StandardErrorContent
}
}
} catch {
Write-Host "✗ $instanceId - Error getting results: $($_.Exception.Message)" -ForegroundColor Red
$allResults += [PSCustomObject]@{
InstanceId = $instanceId
Status = "QueryError"
Error = $_.Exception.Message
}
}
}
return $allResults
}
# Function to analyze aggregated results
function Analyze-AggregatedResults {
param($Results)
Write-Host ""
Write-Host "=== AGGREGATED PERFORMANCE ANALYSIS ===" -ForegroundColor Cyan
# Separate successful vs failed results
$successfulResults = $Results | Where-Object { $_.Duration -ne $null }
$failedResults = $Results | Where-Object { $_.Status -ne "Success" -and $_.Duration -eq $null }
Write-Host "Total Instances: $($Results.Count)" -ForegroundColor White
Write-Host "Successful: $($successfulResults.Count)" -ForegroundColor Green
Write-Host "Failed/Incomplete: $($failedResults.Count)" -ForegroundColor Red
if ($successfulResults.Count -gt 0) {
Write-Host ""
Write-Host "=== PERFORMANCE STATISTICS ===" -ForegroundColor Yellow
# Calculate aggregate statistics
$durations = $successfulResults.Duration
$cpuImpacts = $successfulResults.CPUImpact
$memoryUsage = $successfulResults.MemoryUsedMB
$resourceCounts = $successfulResults.TotalResources
Write-Host "Execution Time:" -ForegroundColor White
Write-Host " Average: $([math]::Round(($durations | Measure-Object -Average).Average, 2)) seconds" -ForegroundColor Gray
Write-Host " Min: $([math]::Round(($durations | Measure-Object -Minimum).Minimum, 2)) seconds" -ForegroundColor Gray
Write-Host " Max: $([math]::Round(($durations | Measure-Object -Maximum).Maximum, 2)) seconds" -ForegroundColor Gray
Write-Host "CPU Impact:" -ForegroundColor White
Write-Host " Average: $([math]::Round(($cpuImpacts | Measure-Object -Average).Average, 2))%" -ForegroundColor Gray
Write-Host " Max: $([math]::Round(($cpuImpacts | Measure-Object -Maximum).Maximum, 2))%" -ForegroundColor Gray
Write-Host "Memory Usage:" -ForegroundColor White
Write-Host " Average: $([math]::Round(($memoryUsage | Measure-Object -Average).Average, 2)) MB" -ForegroundColor Gray
Write-Host " Max: $([math]::Round(($memoryUsage | Measure-Object -Maximum).Maximum, 2)) MB" -ForegroundColor Gray
if ($resourceCounts -and ($resourceCounts | Where-Object { $_ -gt 0 }).Count -gt 0) {
Write-Host "DSC Resources:" -ForegroundColor White
Write-Host " Average: $([math]::Round(($resourceCounts | Measure-Object -Average).Average, 0)) resources" -ForegroundColor Gray
Write-Host " Total across all instances: $(($resourceCounts | Measure-Object -Sum).Sum) resources" -ForegroundColor Gray
}
# Scaling recommendations
Write-Host ""
Write-Host "=== SCALING RECOMMENDATIONS ===" -ForegroundColor Yellow
$maxDuration = ($durations | Measure-Object -Maximum).Maximum
$avgDuration = ($durations | Measure-Object -Average).Average
if ($maxDuration -gt 60) {
Write-Host "⚠️ CRITICAL: Max execution time $maxDuration seconds - stagger deployment windows" -ForegroundColor Red
} elseif ($avgDuration -gt 30) {
Write-Host "⚠️ WARNING: Average $avgDuration seconds - consider optimization" -ForegroundColor Yellow
} else {
Write-Host "✅ GOOD: Execution times acceptable for scale" -ForegroundColor Green
}
$maxCPU = ($cpuImpacts | Measure-Object -Maximum).Maximum
if ($maxCPU -gt 50) {
Write-Host "⚠️ HIGH CPU: Peak $maxCPU% impact - limit concurrent executions" -ForegroundColor Red
} else {
Write-Host "✅ CPU impact manageable for concurrent execution" -ForegroundColor Green
}
# Instance-by-instance breakdown
Write-Host ""
Write-Host "=== INSTANCE BREAKDOWN ===" -ForegroundColor Yellow
$successfulResults | Sort-Object Duration -Descending | ForEach-Object {
$complianceInfo = if ($_.ComplianceRate) { " ($($_.ComplianceRate)% compliant)" } else { "" }
Write-Host "$($_.InstanceId): $($_.Duration)s, CPU +$($_.CPUImpact)%, Memory $($_.MemoryUsedMB)MB$complianceInfo" -ForegroundColor Gray
}
}
# Show failures
if ($failedResults.Count -gt 0) {
Write-Host ""
Write-Host "=== FAILED INSTANCES ===" -ForegroundColor Red
$failedResults | ForEach-Object {
Write-Host "$($_.InstanceId): $($_.Status)" -ForegroundColor Red
if ($_.Error) { Write-Host " Error: $($_.Error)" -ForegroundColor Gray }
}
}
# Export results
Write-Host ""
Write-Host "=== EXPORTING RESULTS ===" -ForegroundColor Yellow
$exportFile = "dsc-performance-results-$(Get-Date -Format 'yyyyMMdd-HHmmss').json"
$Results | ConvertTo-Json -Depth 5 | Out-File -FilePath $exportFile -Encoding UTF8
Write-Host "Results exported to: $exportFile" -ForegroundColor Green
}
# Actually run it automatically for demonstration
Write-Host ""
Write-Host "=== RUNNING AGGREGATION NOW ===" -ForegroundColor Cyan
$results = Get-AggregatedDSCPerformance -CommandId "e0dbc320-9812-44ad-821a-1f93d2100113"
Analyze-AggregatedResults -Results $results
Write-Host ""
Write-Host "=== AGGREGATION COMPLETE ===" -ForegroundColor Green
This will crawl what tag you specify and return a brief window of usage in a companion json. Useful to send out while you are dialing in your configurations to see if you need to split things up – whether it's splitting up total number of benchmarks or adjusting hours on the server's workload.
Handling Scale Limits
AWS has rate limits, and DSC has resource limits. Here's how to handle both:
function Deploy-CISConfigurationAtScale {
<#
.SYNOPSIS
Main function for deploying DSC configurations at scale with intelligent batching.
.DESCRIPTION
Coordinates the entire scale deployment process including instance discovery,
validation, batching strategy, execution monitoring, and results reporting.
#>
param(
[Parameter(Mandatory, HelpMessage="AWS Systems Manager document name for DSC deployment")]
[string]$DocumentName,
[Parameter(Mandatory, HelpMessage="S3 bucket containing the DSC configuration MOF file")]
[string]$ConfigurationS3Bucket,
[Parameter(Mandatory, HelpMessage="S3 key path to the DSC configuration MOF file")]
[string]$ConfigurationS3Key,
[string]$TagKey = "Environment",
[string]$TagValue = "Production",
[string]$ConfigurationName = "CISLevel1Production",
[string]$RequiredModules = "CisDsc,SecurityPolicyDsc,AuditPolicyDsc,NetworkingDsc,ComputerManagementDsc",
# Batching and Rate Limiting Parameters
[int]$BatchSize = 20, # AWS SSM concurrent limit consideration
[int]$DelayBetweenBatchesMinutes = 3, # Rate limiting delay
[int]$TimeoutMinutes = 45, # Per-batch timeout
[int]$MaxRetryAttempts = 2, # Retry failed batches
# DSC Parameters
[bool]$TestMode = $false,
[bool]$ComplianceCheck = $true,
[bool]$EnableVerboseLogging = $false,
[bool]$EnableDebugLogging = $false,
[string]$ProxyUri = "",
[ValidateSet("Never", "IfRequired", "Immediately")]
[string]$RebootBehavior = "Never"
)
Write-Host "=== CIS DSC Scale Deployment Starting ===" -ForegroundColor Cyan
Write-Host "Document: $DocumentName" -ForegroundColor White
Write-Host "Target: $TagKey=$TagValue" -ForegroundColor White
Write-Host "Batch Size: $BatchSize instances" -ForegroundColor White
Write-Host "S3 Configuration: s3://$ConfigurationS3Bucket/$ConfigurationS3Key" -ForegroundColor White
try {
# Step 1: Discover and validate target instances
Write-Host ""
Write-Host "=== STEP 1: Instance Discovery ===" -ForegroundColor Yellow
$instancesJson = aws ec2 describe-instances `
--filters "Name=tag:$TagKey,Values=$TagValue" "Name=instance-state-name,Values=running" `
--query "Reservations[].Instances[].[InstanceId,Tags[?Key=='Name'].Value|[0],InstanceType,LaunchTime]" `
--output json
if (!$instancesJson) {
throw "No instances found with tag $TagKey=$TagValue"
}
$instances = $instancesJson | ConvertFrom-Json
$instanceIds = $instances | ForEach-Object { $_[0] }
Write-Host "Found $($instanceIds.Count) target instances:" -ForegroundColor Green
$instances | ForEach-Object {
$id = $_[0]; $name = $_[1]; $type = $_[2]; $launch = $_[3]
Write-Host " $id - $name ($type) - Launched: $launch" -ForegroundColor Gray
}
# Step 2: Validate SSM connectivity and platform compatibility
Write-Host ""
Write-Host "=== STEP 2: SSM Validation ===" -ForegroundColor Yellow
$ssmInstancesJson = aws ssm describe-instance-information `
--query "InstanceInformationList[?PingStatus=='Online' && PlatformType=='Windows'].[InstanceId,PlatformName,PlatformVersion,LastPingDateTime]" `
--output json
$ssmInstances = ($ssmInstancesJson | ConvertFrom-Json)
$onlineInstanceIds = $ssmInstances | ForEach-Object { $_[0] }
$validInstances = $instanceIds | Where-Object { $_ -in $onlineInstanceIds }
$offlineInstances = $instanceIds | Where-Object { $_ -notin $onlineInstanceIds }
Write-Host "SSM Online & Windows: $($validInstances.Count)" -ForegroundColor Green
if ($offlineInstances.Count -gt 0) {
Write-Host "SSM Offline/Unavailable: $($offlineInstances.Count)" -ForegroundColor Red
$offlineInstances | ForEach-Object {
Write-Host " Offline: $_" -ForegroundColor Red
}
}
if ($validInstances.Count -eq 0) {
throw "No instances are online and SSM-managed for Windows!"
}
# Step 3: Validate S3 configuration exists
Write-Host ""
Write-Host "=== STEP 3: S3 Configuration Validation ===" -ForegroundColor Yellow
try {
$s3ObjectInfo = aws s3api head-object --bucket $ConfigurationS3Bucket --key $ConfigurationS3Key --output json | ConvertFrom-Json
$configSizeKB = [math]::Round($s3ObjectInfo.ContentLength / 1024, 2)
Write-Host "✓ Configuration found: $configSizeKB KB, Last Modified: $($s3ObjectInfo.LastModified)" -ForegroundColor Green
# Adjust batch size based on configuration size
if ($configSizeKB -gt 4096) { # 4MB threshold
$originalBatchSize = $BatchSize
$BatchSize = [math]::Max([math]::Floor($BatchSize / 2), 5) # Reduce batch size for large configs
Write-Host "⚠️ Large configuration detected ($configSizeKB KB) - reducing batch size from $originalBatchSize to $BatchSize" -ForegroundColor Yellow
}
} catch {
throw "S3 configuration validation failed: Cannot access s3://$ConfigurationS3Bucket/$ConfigurationS3Key - $($_.Exception.Message)"
}
# Step 4: Create batches with intelligent sizing
Write-Host ""
Write-Host "=== STEP 4: Batch Creation ===" -ForegroundColor Yellow
# Sort instances by launch time (older instances first - more stable)
$sortedInstances = $validInstances | ForEach-Object {
$instanceId = $_
$instanceInfo = $instances | Where-Object { $_[0] -eq $instanceId }
[PSCustomObject]@{
InstanceId = $instanceId
Name = $instanceInfo[1]
Type = $instanceInfo[2]
LaunchTime = [DateTime]$instanceInfo[3]
}
} | Sort-Object LaunchTime
$batches = @()
for ($i = 0; $i -lt $sortedInstances.Count; $i += $BatchSize) {
$batchEnd = [Math]::Min($i + $BatchSize - 1, $sortedInstances.Count - 1)
$batchInstances = $sortedInstances[$i..$batchEnd]
$batches += ,[PSCustomObject]@{
Number = ($batches.Count + 1)
Instances = $batchInstances
InstanceIds = $batchInstances.InstanceId
Size = $batchInstances.Count
}
}
Write-Host "Created $($batches.Count) batches for deployment" -ForegroundColor Green
$batches | ForEach-Object {
Write-Host " Batch $($_.Number): $($_.Size) instances - $($_.InstanceIds -join ', ')" -ForegroundColor Gray
}
# Step 5: Execute batched deployment
Write-Host ""
Write-Host "=== STEP 5: Batched Deployment Execution ===" -ForegroundColor Yellow
$deploymentResults = @()
$startTime = Get-Date
for ($batchIndex = 0; $batchIndex -lt $batches.Count; $batchIndex++) {
$batch = $batches[$batchIndex]
$batchStartTime = Get-Date
Write-Host ""
Write-Host "--- Batch $($batch.Number) of $($batches.Count) ---" -ForegroundColor Cyan
Write-Host "Instances: $($batch.Size)" -ForegroundColor White
Write-Host "Target IDs: $($batch.InstanceIds -join ', ')" -ForegroundColor Gray
# Construct parameters for your SSM document
$documentParameters = @{
configurationName = $ConfigurationName
configurationS3Bucket = $ConfigurationS3Bucket
configurationS3Key = $ConfigurationS3Key
requiredModules = $RequiredModules
testMode = $TestMode.ToString().ToLower()
complianceCheck = $ComplianceCheck.ToString().ToLower()
EnableVerboseLogging = $EnableVerboseLogging.ToString().ToLower()
EnableDebugLogging = $EnableDebugLogging.ToString().ToLower()
ProxyUri = $ProxyUri
RebootBehavior = $RebootBehavior
MaxRetryAttempts = $MaxRetryAttempts.ToString()
}
# Convert parameters to JSON format for AWS CLI
$parametersJson = $documentParameters.GetEnumerator() | ForEach-Object {
'"' + $_.Key + '":"' + $_.Value + '"'
}
$parametersString = '{' + ($parametersJson -join ',') + '}'
Write-Host "Executing SSM document with parameters..." -ForegroundColor Yellow
Write-Host "Document: $DocumentName" -ForegroundColor Gray
Write-Host "Parameters: $parametersString" -ForegroundColor Gray
# Execute the SSM document for this batch
$batchAttempts = 0
$batchSuccess = $false
$commandId = $null
while ($batchAttempts -lt $MaxRetryAttempts -and -not $batchSuccess) {
$batchAttempts++
try {
Write-Host "Batch attempt $batchAttempts of $MaxRetryAttempts" -ForegroundColor Gray
# Send command to batch instances
$commandId = aws ssm send-command `
--document-name $DocumentName `
--instance-ids $batch.InstanceIds `
--parameters $parametersString `
--timeout-seconds $(($TimeoutMinutes * 60)) `
--query 'Command.CommandId' `
--output text
if (!$commandId -or $commandId -eq "None") {
throw "Failed to send command - no command ID returned"
}
Write-Host "✓ Command sent successfully: $commandId" -ForegroundColor Green
$batchSuccess = $true
} catch {
Write-Host "✗ Batch attempt $batchAttempts failed: $($_.Exception.Message)" -ForegroundColor Red
if ($batchAttempts -lt $MaxRetryAttempts) {
$retryDelay = 30 * $batchAttempts
Write-Host "Waiting $retryDelay seconds before retry..." -ForegroundColor Yellow
Start-Sleep -Seconds $retryDelay
}
}
}
if (-not $batchSuccess) {
Write-Host "✗ Batch $($batch.Number) failed after $MaxRetryAttempts attempts" -ForegroundColor Red
$deploymentResults += [PSCustomObject]@{
BatchNumber = $batch.Number
CommandId = $null
Status = "Failed"
InstanceCount = $batch.Size
FailedInstances = $batch.InstanceIds
Duration = 0
Error = "Failed to send command after $MaxRetryAttempts attempts"
}
continue
}
# Monitor batch execution with progress tracking
Write-Host "Monitoring batch execution..." -ForegroundColor Yellow
$monitoringStartTime = Get-Date
$batchComplete = $false
while (-not $batchComplete) {
$elapsed = ((Get-Date) - $monitoringStartTime).TotalMinutes
if ($elapsed -gt $TimeoutMinutes) {
Write-Host "⏰ Batch $($batch.Number) timed out after $TimeoutMinutes minutes" -ForegroundColor Red
break
}
# Check command status for all instances in batch
$invocationsJson = aws ssm list-command-invocations `
--command-id $commandId `
--query 'CommandInvocations[*].[InstanceId,Status,StatusDetails]' `
--output json
$invocations = $invocationsJson | ConvertFrom-Json
$inProgress = $invocations | Where-Object { $_[1] -eq "InProgress" }
$succeeded = $invocations | Where-Object { $_[1] -eq "Success" }
$failed = $invocations | Where-Object { $_[1] -eq "Failed" }
$cancelled = $invocations | Where-Object { $_[1] -eq "Cancelled" }
$timedOut = $invocations | Where-Object { $_[1] -eq "TimedOut" }
$completed = $succeeded.Count + $failed.Count + $cancelled.Count + $timedOut.Count
$total = $invocations.Count
if ($completed -eq $total) {
$batchComplete = $true
$batchDuration = ((Get-Date) - $batchStartTime).TotalMinutes
Write-Host "✓ Batch $($batch.Number) completed in $([math]::Round($batchDuration, 1)) minutes!" -ForegroundColor Green
Write-Host " Succeeded: $($succeeded.Count)" -ForegroundColor Green
Write-Host " Failed: $($failed.Count)" -ForegroundColor Red
Write-Host " Cancelled: $($cancelled.Count)" -ForegroundColor Yellow
Write-Host " Timed Out: $($timedOut.Count)" -ForegroundColor Red
# Show failed instances
if ($failed.Count -gt 0) {
Write-Host " Failed instances:" -ForegroundColor Red
$failed | ForEach-Object {
Write-Host " - $($_[0]): $($_[2])" -ForegroundColor Red
}
}
# Show timed out instances
if ($timedOut.Count -gt 0) {
Write-Host " Timed out instances:" -ForegroundColor Red
$timedOut | ForEach-Object {
Write-Host " - $($_[0]): $($_[2])" -ForegroundColor Red
}
}
} else {
$percentComplete = [math]::Round(($completed / $total) * 100, 1)
Write-Host "Progress: $completed/$total ($percentComplete%) - $($inProgress.Count) in progress - Elapsed: $([math]::Round($elapsed, 1))min" -ForegroundColor Gray
Start-Sleep -Seconds 30 # Check every 30 seconds
}
}
# Store batch results
$batchResult = [PSCustomObject]@{
BatchNumber = $batch.Number
CommandId = $commandId
Status = if ($succeeded.Count -eq $total) { "Success" } elseif ($succeeded.Count -gt 0) { "PartialSuccess" } else { "Failed" }
InstanceCount = $batch.Size
Succeeded = $succeeded.Count
Failed = $failed.Count
Cancelled = $cancelled.Count
TimedOut = $timedOut.Count
Duration = [math]::Round(((Get-Date) - $batchStartTime).TotalMinutes, 2)
SucceededInstances = $succeeded | ForEach-Object { $_[0] }
FailedInstances = ($failed + $timedOut + $cancelled) | ForEach-Object { $_[0] }
}
$deploymentResults += $batchResult
# Rate limiting delay between batches (except for last batch)
if ($batchIndex -lt $batches.Count - 1) {
Write-Host ""
Write-Host "⏸️ Rate limiting: Waiting $DelayBetweenBatchesMinutes minutes before next batch..." -ForegroundColor Yellow
Write-Host " This prevents AWS API throttling and reduces system load" -ForegroundColor Gray
Start-Sleep -Seconds ($DelayBetweenBatchesMinutes * 60)
}
}
# Step 6: Final deployment summary and analysis
Write-Host ""
Write-Host "=== DEPLOYMENT SUMMARY ===" -ForegroundColor Cyan
$totalDuration = ((Get-Date) - $startTime).TotalMinutes
$totalSucceeded = ($deploymentResults | Measure-Object -Property Succeeded -Sum).Sum
$totalFailed = ($deploymentResults | Measure-Object -Property Failed -Sum).Sum
$totalTimedOut = ($deploymentResults | Measure-Object -Property TimedOut -Sum).Sum
$totalCancelled = ($deploymentResults | Measure-Object -Property Cancelled -Sum).Sum
$avgBatchDuration = ($deploymentResults | Measure-Object -Property Duration -Average).Average
Write-Host "Total Duration: $([math]::Round($totalDuration, 1)) minutes" -ForegroundColor White
Write-Host "Total Instances: $($validInstances.Count)" -ForegroundColor White
Write-Host "Succeeded: $totalSucceeded" -ForegroundColor Green
Write-Host "Failed: $totalFailed" -ForegroundColor Red
Write-Host "Timed Out: $totalTimedOut" -ForegroundColor Red
Write-Host "Cancelled: $totalCancelled" -ForegroundColor Yellow
Write-Host "Success Rate: $([math]::Round(($totalSucceeded / $validInstances.Count) * 100, 1))%" -ForegroundColor White
Write-Host "Average Batch Duration: $([math]::Round($avgBatchDuration, 1)) minutes" -ForegroundColor White
# Batch-by-batch breakdown
Write-Host ""
Write-Host "=== BATCH BREAKDOWN ===" -ForegroundColor Yellow
$deploymentResults | ForEach-Object {
$status = switch ($_.Status) {
"Success" { "✓" }
"PartialSuccess" { "⚠" }
"Failed" { "✗" }
default { "?" }
}
Write-Host "$status Batch $($_.BatchNumber): $($_.Succeeded)/$($_.InstanceCount) succeeded in $($_.Duration)min [Command: $($_.CommandId)]" -ForegroundColor Gray
}
# Export detailed results
$exportFile = "cis-dsc-deployment-$(Get-Date -Format 'yyyyMMdd-HHmmss').json"
$deploymentSummary = [PSCustomObject]@{
DeploymentStartTime = $startTime.ToString('yyyy-MM-dd HH:mm:ss')
DeploymentEndTime = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
TotalDurationMinutes = [math]::Round($totalDuration, 2)
Configuration = @{
DocumentName = $DocumentName
S3Bucket = $ConfigurationS3Bucket
S3Key = $ConfigurationS3Key
ConfigurationName = $ConfigurationName
}
TargetCriteria = @{
TagKey = $TagKey
TagValue = $TagValue
}
BatchingStrategy = @{
BatchSize = $BatchSize
DelayBetweenBatchesMinutes = $DelayBetweenBatchesMinutes
TimeoutMinutes = $TimeoutMinutes
}
Results = @{
TotalInstances = $validInstances.Count
Succeeded = $totalSucceeded
Failed = $totalFailed
TimedOut = $totalTimedOut
Cancelled = $totalCancelled
SuccessRate = [math]::Round(($totalSucceeded / $validInstances.Count) * 100, 2)
}
BatchResults = $deploymentResults
}
$deploymentSummary | ConvertTo-Json -Depth 5 | Out-File -FilePath $exportFile -Encoding UTF8
Write-Host ""
Write-Host "📊 Detailed results exported to: $exportFile" -ForegroundColor Green
return $deploymentSummary
} catch {
Write-Host ""
Write-Host "❌ Scale deployment failed: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Line: $($_.InvocationInfo.ScriptLineNumber)" -ForegroundColor Red
return $null
}
}
Optimizing MOF File Distribution
S3 is great, but at scale you need to optimize data transfer. This takes a bit of prep.
# S3 Transfer Acceleration Setup
# Save as: Set-S3Accelerate.ps1
# Run once to enable faster S3 downloads for your DSC configurations
param(
[Parameter(Mandatory)]
[string]$BucketName = "systems-manager-windows-server-dsc-configurations"
)
Write-Host "Setting up S3 Transfer Acceleration for bucket: $BucketName" -ForegroundColor Cyan
try {
# Enable S3 Transfer Acceleration
aws s3api put-bucket-accelerate-configuration `
--bucket $BucketName `
--accelerate-configuration Status=Enabled
Write-Host "✓ S3 Transfer Acceleration enabled successfully" -ForegroundColor Green
# Verify it was enabled
$config = aws s3api get-bucket-accelerate-configuration --bucket $BucketName --output json | ConvertFrom-Json
if ($config.Status -eq "Enabled") {
Write-Host "✓ Acceleration confirmed enabled" -ForegroundColor Green
Write-Host "Your accelerated endpoint will be: https://$BucketName.s3-accelerate.amazonaws.com" -ForegroundColor White
} else {
Write-Host "⚠️ Acceleration may not be fully enabled yet" -ForegroundColor Yellow
}
} catch {
Write-Host "❌ Failed to enable S3 Transfer Acceleration: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Check that:" -ForegroundColor Yellow
Write-Host " - You have s3:PutAccelerateConfiguration permission" -ForegroundColor Gray
Write-Host " - The bucket name is correct" -ForegroundColor Gray
Write-Host " - AWS CLI is configured properly" -ForegroundColor Gray
exit 1
}
Write-Host ""
Write-Host "S3 Transfer Acceleration setup complete!" -ForegroundColor Green
Write-Host "This will speed up MOF file downloads globally." -ForegroundColor White
<#
.SYNOPSIS
Calculates SHA256 hash for MOF files from local filesystem or S3.
.DESCRIPTION
This script provides functions to calculate SHA256 hashes of MOF files from either
local storage or directly from S3 objects. Essential for AWS Systems Manager State
Manager caching and validation without requiring local file access.
.PARAMETER MOFFilePath
The full path to a local MOF file for hash calculation.
.PARAMETER S3Bucket
The S3 bucket name containing the MOF file.
.PARAMETER S3Key
The S3 object key (path) to the MOF file.
.PARAMETER S3Uri
Full S3 URI (s3://bucket/key) to the MOF file.
.EXAMPLE
Get-MOFHash -MOFFilePath "C:\DSC\MyConfig.mof"
Calculates hash for local MOF file.
.EXAMPLE
Get-MOFHash -S3Bucket "my-dsc-configs" -S3Key "production/webserver.mof"
Calculates hash for MOF file stored in S3.
.EXAMPLE
Get-MOFHash -S3Uri "s3://my-dsc-configs/production/webserver.mof"
Calculates hash using full S3 URI.
.NOTES
Author: Jeffrey Stuhr
Purpose: AWS Systems Manager MOF file validation and caching
Requirements: PowerShell 5.0+, AWS CLI or AWS PowerShell module
#>
function Test-AWSConfiguration {
<#
.SYNOPSIS
Validates AWS CLI and PowerShell module availability.
#>
$hasAWSCLI = $false
$hasAWSModule = $false
# Test AWS CLI
try {
$null = aws --version 2>$null
if ($LASTEXITCODE -eq 0) {
$hasAWSCLI = $true
}
} catch { }
# Test AWS PowerShell modules
if ((Get-Module -Name AWS.Tools.S3 -ListAvailable) -or (Get-Module -Name AWSPowerShell -ListAvailable)) {
$hasAWSModule = $true
}
return @{
HasCLI = $hasAWSCLI
HasModule = $hasAWSModule
IsConfigured = $hasAWSCLI -or $hasAWSModule
}
}
function Get-MOFHash {
<#
.SYNOPSIS
Calculates SHA256 hash for MOF files from local or S3 sources.
.DESCRIPTION
Generates SHA256 hash for MOF files from local filesystem or S3, with deployment
guidance and automatic clipboard copying for easy use in AWS Systems Manager.
.PARAMETER MOFFilePath
Full path to local MOF file to analyze.
.PARAMETER S3Bucket
S3 bucket name containing the MOF file.
.PARAMETER S3Key
S3 object key (path) to the MOF file.
.PARAMETER S3Uri
Full S3 URI in format s3://bucket/key.
.OUTPUTS
Returns hashtable with file information including path, size, hash, and metadata.
#>
[CmdletBinding(DefaultParameterSetName = 'Local')]
param(
[Parameter(
Mandatory = $true,
ParameterSetName = 'Local',
HelpMessage = "Enter the full path to the local MOF file"
)]
[ValidateNotNullOrEmpty()]
[string]$MOFFilePath,
[Parameter(
Mandatory = $true,
ParameterSetName = 'S3BucketKey',
HelpMessage = "Enter the S3 bucket name"
)]
[ValidateNotNullOrEmpty()]
[string]$S3Bucket,
[Parameter(
Mandatory = $true,
ParameterSetName = 'S3BucketKey',
HelpMessage = "Enter the S3 object key (path)"
)]
[ValidateNotNullOrEmpty()]
[string]$S3Key,
[Parameter(
Mandatory = $true,
ParameterSetName = 'S3Uri',
HelpMessage = "Enter the full S3 URI (s3://bucket/key)"
)]
[ValidateNotNullOrEmpty()]
[string]$S3Uri
)
try {
if ($PSCmdlet.ParameterSetName -eq 'Local') {
# Handle local file processing
Write-Host "Calculating hash for local MOF file..." -ForegroundColor Cyan
return Get-LocalMOFHash -FilePath $MOFFilePath
}
else {
# Validate AWS configuration before proceeding
$awsConfig = Test-AWSConfiguration
if (-not $awsConfig.IsConfigured) {
throw "AWS CLI or AWS PowerShell module is required for S3 operations. Please install AWS CLI or AWS PowerShell module."
}
# Handle S3 file processing
if ($PSCmdlet.ParameterSetName -eq 'S3Uri') {
# Parse S3 URI to extract bucket and key
if ($S3Uri -match '^s3://([^/]+)/(.+)$') {
$S3Bucket = $matches[1]
$S3Key = $matches[2]
} else {
throw "Invalid S3 URI format. Expected: s3://bucket/key"
}
}
Write-Host "Calculating hash for S3 MOF file..." -ForegroundColor Cyan
Write-Host "Bucket: $S3Bucket" -ForegroundColor Gray
Write-Host "Key: $S3Key" -ForegroundColor Gray
return Get-S3MOFHash -Bucket $S3Bucket -Key $S3Key
}
}
catch {
throw "Failed to calculate MOF hash: $($_.Exception.Message)"
}
}
function Get-LocalMOFHash {
param([string]$FilePath)
if (-not (Test-Path $FilePath)) {
throw "MOF file not found: $FilePath"
}
$fileInfo = Get-Item $FilePath
$hash = (Get-FileHash $FilePath -Algorithm SHA256).Hash
$sizeKB = [math]::Round($fileInfo.Length / 1024, 2)
Write-Host ""
Write-Host "=== Local MOF File Information ===" -ForegroundColor Yellow
Write-Host "File Path: $FilePath" -ForegroundColor White
Write-Host "File Size: $sizeKB KB" -ForegroundColor White
Write-Host "SHA256 Hash: $hash" -ForegroundColor Green
Show-DeploymentGuidance -SizeKB $sizeKB
Copy-HashToClipboard -Hash $hash
return @{
Source = "Local"
FilePath = $FilePath
FileName = $fileInfo.Name
SizeBytes = $fileInfo.Length
SizeKB = $sizeKB
Hash = $hash
LastModified = $fileInfo.LastWriteTime
S3Bucket = $null
S3Key = $null
}
}
function Get-S3MOFHash {
param(
[string]$Bucket,
[string]$Key
)
# Try AWS CLI first (most commonly available)
$s3Info = Get-S3ObjectInfo -Bucket $Bucket -Key $Key -Method "CLI"
if (-not $s3Info) {
# Fallback to AWS PowerShell module
$s3Info = Get-S3ObjectInfo -Bucket $Bucket -Key $Key -Method "PowerShell"
}
if (-not $s3Info) {
throw "Unable to retrieve S3 object information. Ensure AWS CLI is configured or AWS PowerShell module is installed."
}
$sizeKB = [math]::Round($s3Info.Size / 1024, 2)
Write-Host ""
Write-Host "=== S3 MOF File Information ===" -ForegroundColor Yellow
Write-Host "S3 Location: s3://$Bucket/$Key" -ForegroundColor White
Write-Host "File Size: $sizeKB KB" -ForegroundColor White
Write-Host "SHA256 Hash: $($s3Info.Hash)" -ForegroundColor Green
Write-Host "Last Modified: $($s3Info.LastModified)" -ForegroundColor Gray
Show-DeploymentGuidance -SizeKB $sizeKB
Copy-HashToClipboard -Hash $s3Info.Hash
return @{
Source = "S3"
FilePath = "s3://$Bucket/$Key"
FileName = Split-Path $Key -Leaf
SizeBytes = $s3Info.Size
SizeKB = $sizeKB
Hash = $s3Info.Hash
LastModified = $s3Info.LastModified
S3Bucket = $Bucket
S3Key = $Key
}
}
function Get-S3ObjectInfo {
param(
[string]$Bucket,
[string]$Key,
[string]$Method
)
if ($Method -eq "CLI") {
try {
# Method 1: Use AWS CLI to get object metadata
Write-Host "Using AWS CLI to retrieve S3 object information..." -ForegroundColor Gray
# First check if object exists
$s3Output = aws s3api head-object --bucket $Bucket --key $Key --output json 2>$null
if ($LASTEXITCODE -ne 0) {
throw "S3 object not found or access denied: s3://$Bucket/$Key"
}
$s3Metadata = $s3Output | ConvertFrom-Json
# AWS S3 provides ETag which is MD5 for simple uploads, but we need SHA256
# For SHA256, we need to check if it's stored as metadata or calculate it
$sha256Hash = $null
# Check if SHA256 is stored as metadata
if ($s3Metadata.Metadata -and $s3Metadata.Metadata.'sha256') {
$sha256Hash = $s3Metadata.Metadata.'sha256'
Write-Host "✓ Found SHA256 in object metadata" -ForegroundColor Green
}
elseif ($s3Metadata.Metadata -and $s3Metadata.Metadata.'x-amz-content-sha256') {
$sha256Hash = $s3Metadata.Metadata.'x-amz-content-sha256'
Write-Host "✓ Found SHA256 in AMZ content metadata" -ForegroundColor Green
}
else {
# Calculate SHA256 by downloading and hashing (for smaller files)
Write-Host "SHA256 not in metadata, calculating..." -ForegroundColor Yellow
$sha256Hash = Get-S3ObjectSHA256 -Bucket $Bucket -Key $Key -Method "CLI"
}
return @{
Size = [long]$s3Metadata.ContentLength
Hash = $sha256Hash
LastModified = [DateTime]$s3Metadata.LastModified
ETag = $s3Metadata.ETag.Trim('"')
}
}
catch {
Write-Host "AWS CLI method failed: $($_.Exception.Message)" -ForegroundColor Yellow
return $null
}
}
elseif ($Method -eq "PowerShell") {
try {
# Method 2: Use AWS PowerShell module
Write-Host "Using AWS PowerShell module..." -ForegroundColor Gray
if (-not (Get-Module -Name AWS.Tools.S3 -ListAvailable)) {
if (-not (Get-Module -Name AWSPowerShell -ListAvailable)) {
throw "Neither AWS.Tools.S3 nor AWSPowerShell module is installed"
}
}
$s3Object = Get-S3Object -BucketName $Bucket -Key $Key -ErrorAction Stop
# Check for SHA256 in object metadata first
$sha256Hash = $null
$s3ObjectMetadata = Get-S3ObjectMetadata -BucketName $Bucket -Key $Key -ErrorAction Stop
if ($s3ObjectMetadata.Metadata -and $s3ObjectMetadata.Metadata['sha256']) {
$sha256Hash = $s3ObjectMetadata.Metadata['sha256']
Write-Host "✓ Found SHA256 in object metadata" -ForegroundColor Green
}
else {
# Calculate SHA256 by downloading and hashing
Write-Host "SHA256 not in metadata, calculating..." -ForegroundColor Yellow
$sha256Hash = Get-S3ObjectSHA256 -Bucket $Bucket -Key $Key -Method "PowerShell"
}
return @{
Size = $s3Object.Size
Hash = $sha256Hash
LastModified = $s3Object.LastModified
ETag = $s3Object.ETag
}
}
catch {
Write-Host "AWS PowerShell method failed: $($_.Exception.Message)" -ForegroundColor Yellow
return $null
}
}
return $null
}
function Get-S3ObjectSHA256 {
param(
[string]$Bucket,
[string]$Key,
[string]$Method = "CLI"
)
try {
# For files under 100MB, download temporarily and hash
# For larger files, this would need streaming or alternative approach
$tempFile = [System.IO.Path]::GetTempFileName()
Write-Host "Downloading S3 object to calculate SHA256..." -ForegroundColor Gray
if ($Method -eq "CLI") {
# Download using AWS CLI
aws s3 cp "s3://$Bucket/$Key" $tempFile --quiet 2>$null
if ($LASTEXITCODE -eq 0) {
$hash = (Get-FileHash $tempFile -Algorithm SHA256).Hash
Remove-Item $tempFile -Force
Write-Host "✓ SHA256 calculated from downloaded file" -ForegroundColor Green
return $hash
}
else {
throw "Failed to download S3 object using AWS CLI"
}
}
elseif ($Method -eq "PowerShell") {
# Download using AWS PowerShell module
Read-S3Object -BucketName $Bucket -Key $Key -File $tempFile -ErrorAction Stop
$hash = (Get-FileHash $tempFile -Algorithm SHA256).Hash
Remove-Item $tempFile -Force
Write-Host "✓ SHA256 calculated from downloaded file" -ForegroundColor Green
return $hash
}
else {
throw "Invalid download method specified"
}
}
catch {
if (Test-Path $tempFile) {
Remove-Item $tempFile -Force
}
Write-Host "⚠️ Could not calculate SHA256: $($_.Exception.Message)" -ForegroundColor Yellow
return "HASH_CALCULATION_FAILED"
}
}
function Show-DeploymentGuidance {
param([double]$SizeKB)
Write-Host ""
if ($SizeKB -gt 4096) {
Write-Host "⚠️ WARNING: Large MOF file ($SizeKB KB)" -ForegroundColor Red
Write-Host " Consider reducing batch sizes for deployment" -ForegroundColor Yellow
Write-Host " Monitor Systems Manager execution timeouts" -ForegroundColor Yellow
}
elseif ($SizeKB -gt 2048) {
Write-Host "⚠️ NOTICE: Medium-sized MOF file ($SizeKB KB)" -ForegroundColor Yellow
Write-Host " Monitor deployment performance" -ForegroundColor Gray
}
else {
Write-Host "✓ MOF file size is reasonable for deployment" -ForegroundColor Green
}
}
function Copy-HashToClipboard {
param([string]$Hash)
try {
$Hash | Set-Clipboard
Write-Host "✓ Hash copied to clipboard" -ForegroundColor Green
}
catch {
Write-Host "ℹ️ Hash not copied to clipboard (Set-Clipboard not available)" -ForegroundColor Gray
}
}
# Script execution when run directly
if ($MyInvocation.InvocationName -ne '.' -and $args.Count -gt 0) {
# Parse command line arguments manually to avoid parameter conflicts
$paramHash = @{}
for ($i = 0; $i -lt $args.Count; $i += 2) {
if ($i + 1 -lt $args.Count) {
$paramName = $args[$i] -replace '^-', ''
$paramValue = $args[$i + 1]
$paramHash[$paramName] = $paramValue
}
}
try {
if ($paramHash.S3Uri) {
Get-MOFHash -S3Uri $paramHash.S3Uri
}
elseif ($paramHash.S3Bucket -and $paramHash.S3Key) {
Get-MOFHash -S3Bucket $paramHash.S3Bucket -S3Key $paramHash.S3Key
}
elseif ($paramHash.MOFFilePath) {
Get-MOFHash -MOFFilePath $paramHash.MOFFilePath
}
else {
Write-Host "Usage examples:" -ForegroundColor Yellow
Write-Host " .\Get-MOFHash.ps1 -MOFFilePath 'C:\DSC\config.mof'" -ForegroundColor Gray
Write-Host " .\Get-MOFHash.ps1 -S3Bucket 'my-bucket' -S3Key 'configs/prod.mof'" -ForegroundColor Gray
Write-Host " .\Get-MOFHash.ps1 -S3Uri 's3://my-bucket/configs/prod.mof'" -ForegroundColor Gray
}
}
catch {
Write-Error "Script execution failed: $($_.Exception.Message)"
exit 1
}
}
elseif ($MyInvocation.InvocationName -ne '.' -and $args.Count -eq 0) {
Write-Host "Usage examples:" -ForegroundColor Yellow
Write-Host " .\Get-MOFHash.ps1 -MOFFilePath 'C:\DSC\config.mof'" -ForegroundColor Gray
Write-Host " .\Get-MOFHash.ps1 -S3Bucket 'my-bucket' -S3Key 'configs/prod.mof'" -ForegroundColor Gray
Write-Host " .\Get-MOFHash.ps1 -S3Uri 's3://my-bucket/configs/prod.mof'" -ForegroundColor Gray
Write-Host ""
Write-Host "Or import as module:" -ForegroundColor Yellow
Write-Host " . .\Get-MOFHash.ps1" -ForegroundColor Gray
Write-Host " Get-MOFHash -MOFFilePath 'C:\DSC\config.mof'" -ForegroundColor Gray
}
# Enable S3 Transfer Acceleration for global deployments
aws s3api put-bucket-accelerate-configuration `
--bucket systems-manager-windows-server-dsc-configurations `
--accelerate-configuration Status=Enabled
# Implement intelligent caching
$cachingScript = @'
function Get-DSCConfiguration {
param(
[string]$S3Bucket,
[string]$S3Key,
[string]$ExpectedHash
)
$cacheDir = "C:\DSCCache"
$cachedMof = Join-Path $cacheDir "cached.mof"
$hashFile = Join-Path $cacheDir "cached.hash"
# Create cache directory
New-Item -Path $cacheDir -ItemType Directory -Force | Out-Null
# Check if we have a valid cached version
if ((Test-Path $cachedMof) -and (Test-Path $hashFile)) {
$cachedHash = Get-Content $hashFile
if ($cachedHash -eq $ExpectedHash) {
Write-Output "Using cached MOF file (hash match)"
return $cachedMof
}
}
# Download new version
Write-Output "Downloading MOF from S3..."
$tempFile = Join-Path $cacheDir "download.mof"
# Use S3 acceleration endpoint if available
$acceleratedUrl = "https://$S3Bucket.s3-accelerate.amazonaws.com/$S3Key"
try {
# Try accelerated endpoint first
Invoke-WebRequest -Uri $acceleratedUrl -OutFile $tempFile -UseBasicParsing
} catch {
# Fall back to standard S3
Read-S3Object -BucketName $S3Bucket -Key $S3Key -File $tempFile
}
# Verify download
$downloadHash = (Get-FileHash $tempFile -Algorithm SHA256).Hash
if ($downloadHash -ne $ExpectedHash) {
throw "Downloaded file hash mismatch"
}
# Update cache
Move-Item $tempFile $cachedMof -Force
$downloadHash | Out-File $hashFile -Force
return $cachedMof
}
'@
# Create SSM document with caching
$documentWithCaching = @{
schemaVersion = "2.2"
description = "Apply DSC with intelligent caching"
parameters = @{
configurationHash = @{
type = "String"
description = "SHA256 hash of the MOF file"
}
}
mainSteps = @(
@{
action = "aws:runPowerShellScript"
name = "applyDSCWithCache"
inputs = @{
runCommand = @(
$cachingScript,
"",
"# Use the caching function",
'$mofPath = Get-DSCConfiguration -S3Bucket "{{configurationS3Bucket}}" -S3Key "{{configurationS3Key}}" -ExpectedHash "{{configurationHash}}"',
"Start-DscConfiguration -Path (Split-Path $mofPath) -Wait -Force"
)
}
}
)
}
# Create the enhanced document
$documentWithCaching | ConvertTo-Json -Depth 10 |
Out-File -FilePath "DSC-Apply-With-Caching.json"
aws ssm create-document `
--name "DSC-Apply-CIS-Cached" `
--document-type "Command" `
--content file://DSC-Apply-With-Caching.json
New ssm-document-cached-dsc.json
that we'll be uploading. note: I kind of got in the groove of adding unicode outputs and that messed this up so bad lol
{
"schemaVersion": "2.2",
"description": "Apply DSC configuration with intelligent MOF caching and S3 acceleration",
"parameters": {
"configurationS3Bucket": {
"type": "String",
"description": "S3 bucket containing the MOF file",
"allowedPattern": "^[a-z0-9.-]+$"
},
"configurationS3Key": {
"type": "String",
"description": "S3 key for the MOF file",
"allowedPattern": "^[a-zA-Z0-9!_.*'()/-]+$"
},
"configurationHash": {
"type": "String",
"description": "SHA256 hash of the MOF file for cache validation",
"allowedPattern": "^[A-Fa-f0-9]{64}$"
},
"useAcceleratedEndpoint": {
"type": "String",
"description": "Use S3 Transfer Acceleration if available",
"default": "true",
"allowedValues": ["true", "false"]
},
"maxCacheAgeDays": {
"type": "String",
"description": "Maximum age of cached files in days",
"default": "7",
"allowedPattern": "^[1-9][0-9]*$"
}
},
"mainSteps": [
{
"action": "aws:runPowerShellScript",
"name": "applyDSCWithCaching",
"inputs": {
"timeoutSeconds": "7200",
"runCommand": [
"# DSC Configuration with Intelligent Caching",
"param(",
" [string]$S3Bucket = '{{configurationS3Bucket}}',",
" [string]$S3Key = '{{configurationS3Key}}',",
" [string]$ExpectedHash = '{{configurationHash}}',",
" [bool]$UseAccelerated = [bool]::Parse('{{useAcceleratedEndpoint}}'),",
" [int]$MaxCacheAge = [int]::Parse('{{maxCacheAgeDays}}')",
")",
"",
"$ErrorActionPreference = 'Stop'",
"$startTime = Get-Date",
"",
"Write-Output '=== DSC Configuration with Caching Started ==='",
"Write-Output \"S3 Location: s3://$S3Bucket/$S3Key\"",
"Write-Output \"Expected Hash: $ExpectedHash\"",
"Write-Output \"Use Acceleration: $UseAccelerated\"",
"",
"try {",
" # Setup cache directory",
" $cacheDir = 'C:\\DSCCache'",
" if (-not (Test-Path $cacheDir)) {",
" New-Item -Path $cacheDir -ItemType Directory -Force | Out-Null",
" Write-Output '[OK] Cache directory created'",
" }",
"",
" # Define cache file paths",
" $configId = ($S3Key -replace '[\\\\/:*?\"<>|]', '_').ToLower()",
" $cachedMof = Join-Path $cacheDir \"$configId.mof\"",
" $hashFile = Join-Path $cacheDir \"$configId.hash\"",
"",
" # Check for valid cached version",
" $cacheValid = $false",
" if ((Test-Path $cachedMof) -and (Test-Path $hashFile)) {",
" try {",
" $cachedHash = (Get-Content $hashFile -ErrorAction Stop).Trim()",
" if ($cachedHash -eq $ExpectedHash) {",
" $actualHash = (Get-FileHash $cachedMof -Algorithm SHA256).Hash",
" if ($actualHash -eq $ExpectedHash) {",
" $cacheValid = $true",
" $cacheSize = (Get-Item $cachedMof).Length",
" Write-Output \"[OK] Valid cached MOF found: $cacheSize bytes\"",
" }",
" }",
" } catch {",
" Write-Output \"[WARN] Cache validation failed: $($_.Exception.Message)\"",
" }",
" }",
"",
" # Download if cache is invalid",
" if (-not $cacheValid) {",
" Write-Output 'Downloading MOF from S3...'",
" $tempFile = Join-Path $cacheDir \"download_$(Get-Random).mof\"",
" $downloadSuccess = $false",
"",
" # Try AWS CLI first (supports acceleration)",
" try {",
" $awsResult = & aws s3 cp \"s3://$S3Bucket/$S3Key\" $tempFile 2>&1",
" if ($LASTEXITCODE -eq 0 -and (Test-Path $tempFile)) {",
" $downloadSize = (Get-Item $tempFile).Length",
" if ($downloadSize -gt 0) {",
" Write-Output \"[OK] Download successful: $downloadSize bytes\"",
" $downloadSuccess = $true",
" }",
" }",
" } catch {",
" Write-Output \"AWS CLI download failed: $($_.Exception.Message)\"",
" }",
"",
" # Fallback to PowerShell if AWS CLI failed",
" if (-not $downloadSuccess) {",
" try {",
" if (Get-Module -ListAvailable -Name 'AWS.Tools.S3') {",
" Import-Module AWS.Tools.S3 -Force",
" Read-S3Object -BucketName $S3Bucket -Key $S3Key -File $tempFile",
" if (Test-Path $tempFile) {",
" $downloadSize = (Get-Item $tempFile).Length",
" Write-Output \"[OK] PowerShell download successful: $downloadSize bytes\"",
" $downloadSuccess = $true",
" }",
" } else {",
" throw 'AWS PowerShell module not available'",
" }",
" } catch {",
" Write-Output \"PowerShell download failed: $($_.Exception.Message)\"",
" }",
" }",
"",
" if (-not $downloadSuccess) {",
" throw 'All download methods failed'",
" }",
"",
" # Verify downloaded file hash",
" $downloadHash = (Get-FileHash $tempFile -Algorithm SHA256).Hash",
" if ($downloadHash -ne $ExpectedHash) {",
" Remove-Item $tempFile -Force -ErrorAction SilentlyContinue",
" throw \"Hash verification failed. Expected: $ExpectedHash, Got: $downloadHash\"",
" }",
"",
" # Update cache atomically",
" if (Test-Path $cachedMof) { Remove-Item $cachedMof -Force }",
" if (Test-Path $hashFile) { Remove-Item $hashFile -Force }",
" Move-Item $tempFile $cachedMof -Force",
" $ExpectedHash | Out-File $hashFile -Encoding ASCII -Force",
" Write-Output '[OK] Cache updated successfully'",
" }",
"",
" # Prepare working directory for DSC",
" $workDir = \"C:\\Temp\\DSC-$(Get-Random)\"",
" New-Item -Path $workDir -ItemType Directory -Force | Out-Null",
" Copy-Item $cachedMof (Join-Path $workDir 'localhost.mof') -Force",
"",
" # Apply DSC configuration",
" Write-Output 'Applying DSC configuration...'",
" Start-DscConfiguration -Path $workDir -Wait -Force -Verbose",
" Write-Output '[OK] DSC configuration applied successfully'",
"",
" # Compliance check",
" Write-Output 'Running compliance validation...'",
" $testResult = Test-DscConfiguration -Detailed",
" $totalResources = $testResult.ResourcesInDesiredState.Count + $testResult.ResourcesNotInDesiredState.Count",
"",
" if ($testResult.InDesiredState) {",
" Write-Output \"[OK] All $totalResources resources in desired state\"",
" $status = 'Success'",
" } else {",
" Write-Output \"[WARN] $($testResult.ResourcesNotInDesiredState.Count) of $totalResources resources not compliant\"",
" $status = 'SuccessWithWarnings'",
" }",
"",
" # Cleanup",
" Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue",
"",
" # Final result",
" $duration = ((Get-Date) - $startTime).TotalMinutes",
" $result = @{",
" Status = $status",
" Duration = [math]::Round($duration, 2)",
" TotalResources = $totalResources",
" CompliantResources = $testResult.ResourcesInDesiredState.Count",
" NonCompliantResources = $testResult.ResourcesNotInDesiredState.Count",
" CacheUsed = $cacheValid",
" ConfigurationHash = $ExpectedHash",
" }",
"",
" Write-Output '=== DSC Configuration Complete ==='",
" Write-Output \"Status: $($result.Status)\"",
" Write-Output \"Duration: $($result.Duration) minutes\"",
" Write-Output \"Resources: $($result.CompliantResources)/$($result.TotalResources) compliant\"",
" Write-Output \"Cache Used: $($result.CacheUsed)\"",
"",
" # Output JSON result for programmatic consumption",
" $result | ConvertTo-Json -Compress",
"",
"} catch {",
" $errorResult = @{",
" Status = 'Failed'",
" Error = $_.Exception.Message",
" Duration = ((Get-Date) - $startTime).TotalMinutes",
" }",
"",
" Write-Output '=== DSC Configuration Failed ==='",
" Write-Output \"Error: $($_.Exception.Message)\"",
" ",
" $errorResult | ConvertTo-Json -Compress",
" exit 1",
"}"
]
},
"outputs": [
{
"Name": "DSCResult",
"Selector": "$",
"Type": "StringMap"
}
]
}
],
"outputs": [
"applyDSCWithCaching.DSCResult"
]
}
And finally run New-SSMCacheDocument.ps1
to get it up and ready for your association.
<#
.SYNOPSIS
Creates or updates an AWS Systems Manager document for DSC configuration deployment with caching.
.DESCRIPTION
This script automates the creation and management of AWS SSM documents that enable
PowerShell DSC configuration deployment with intelligent caching capabilities.
It validates JSON document content, handles existing document updates, and sets
appropriate permissions for the SSM document.
.PARAMETER DocumentName
The name of the SSM document to create or update. Default is "DSC-Apply-With-Caching".
.PARAMETER JsonFilePath
Path to the JSON file containing the SSM document definition.
Default is ".\ssm-document-cached-dsc.json".
.PARAMETER Verbose
Show detailed troubleshooting information and alternative approaches when errors occur.
.EXAMPLE
.\New-SSMCacheDocument.ps1
Creates the SSM document with default parameters.
.EXAMPLE
.\New-SSMCacheDocument.ps1 -DocumentName "MyCustomDSCDoc" -JsonFilePath "C:\configs\my-doc.json" -Verbose
Creates an SSM document with custom name and JSON file path, showing verbose output.
.NOTES
Author: Jeffrey Stuhr
Purpose: AWS Systems Manager DSC deployment automation
Requirements:
- AWS CLI configured with appropriate permissions
- Valid JSON SSM document definition file
- IAM permissions: ssm:CreateDocument, ssm:UpdateDocument, ssm:DescribeDocument, ssm:ModifyDocumentPermission
#>
[CmdletBinding()]
param(
[Parameter(HelpMessage = "Name of the SSM document to create or update")]
[string]$DocumentName = "DSC-Apply-With-Caching",
[Parameter(HelpMessage = "Path to the JSON file containing SSM document definition")]
[string]$JsonFilePath = ".\ssm-document-cached-dsc.json"
)
# Display script execution start message
Write-Host "Creating SSM Document: $DocumentName" -ForegroundColor Cyan
# Validate that the required JSON document file exists
if (-not (Test-Path $JsonFilePath)) {
Write-Host "❌ JSON file not found: $JsonFilePath" -ForegroundColor Red
Write-Host "Make sure you have saved the SSM Document JSON as: $JsonFilePath" -ForegroundColor Yellow
exit 1
}
try {
# Validate JSON file structure and content before attempting AWS operations
Write-Host "Validating JSON file..." -ForegroundColor Yellow
# Read the JSON file with explicit UTF-8 encoding to avoid encoding issues
$jsonContent = Get-Content $JsonFilePath -Raw -Encoding UTF8 | ConvertFrom-Json
Write-Host "✓ JSON file is valid" -ForegroundColor Green
Write-Host " Schema Version: $($jsonContent.schemaVersion)" -ForegroundColor Gray
Write-Host " Description: $($jsonContent.description)" -ForegroundColor Gray
Write-Host " Parameters: $($jsonContent.parameters.Count)" -ForegroundColor Gray
# Create a safe temporary file path without spaces or special characters
Write-Host "Preparing JSON content for AWS CLI..." -ForegroundColor Yellow
# Use current directory for temp file to avoid path issues
$tempJsonFile = ".\ssm-temp-$(Get-Random).json"
# Convert the JSON content back to string with proper formatting
$jsonString = $jsonContent | ConvertTo-Json -Depth 10
# Save as UTF-8 without BOM (required by AWS CLI)
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$fullTempPath = Join-Path (Get-Location).Path $tempJsonFile
[System.IO.File]::WriteAllText($fullTempPath, $jsonString, $utf8NoBom)
Write-Host "✓ Temporary JSON file created: $tempJsonFile" -ForegroundColor Green
# Alternative approach: Pass JSON content directly as string instead of file
Write-Host "Using direct JSON content approach..." -ForegroundColor Yellow
# Escape the JSON for command line usage
$escapedJson = $jsonString -replace '"', '\"'
# Check if an SSM document with the same name already exists
Write-Host "Checking if document already exists..." -ForegroundColor Yellow
$existingDoc = aws ssm describe-document --name $DocumentName --output json 2>$null
if ($LASTEXITCODE -eq 0) {
# Document exists - prompt user for update confirmation
Write-Host "⚠️ Document '$DocumentName' already exists" -ForegroundColor Yellow
$response = Read-Host "Do you want to update it? (y/N)"
if ($response -eq 'y' -or $response -eq 'Y') {
# Update existing document with new content - try file approach first
Write-Host "Updating existing document..." -ForegroundColor Yellow
# Method 1: Try with temporary file
$result = aws ssm update-document `
--name $DocumentName `
--content "file://$tempJsonFile" `
--document-format JSON `
--document-version '$LATEST' `
--output json 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "File method failed, trying stdin approach..." -ForegroundColor Yellow
# Method 2: Use stdin approach
$result = $jsonString | aws ssm update-document `
--name $DocumentName `
--content "file://-" `
--document-format JSON `
--document-version '$LATEST' `
--output json
}
# Enhanced error handling for duplicate content
if ($LASTEXITCODE -eq 0) {
Write-Host "✓ Document updated successfully" -ForegroundColor Green
try {
$updateInfo = $result | ConvertFrom-Json
Write-Host " New Version: $($updateInfo.DocumentDescription.DocumentVersion)" -ForegroundColor Gray
} catch {
Write-Host " Update completed (version info not available)" -ForegroundColor Gray
}
} elseif ($LASTEXITCODE -eq 254) {
# Handle duplicate content gracefully - this is actually success
Write-Host "⚠️ No changes detected - document content is identical" -ForegroundColor Yellow
Write-Host "✓ Document '$DocumentName' is already current" -ForegroundColor Green
Write-Host " No update needed - existing document matches your JSON file" -ForegroundColor Gray
} else {
Write-Host "Update error output: $result" -ForegroundColor Red
throw "Failed to update document - AWS CLI error code: $LASTEXITCODE"
}
} else {
# User chose not to update - exit gracefully
Write-Host "Document update cancelled" -ForegroundColor Yellow
Remove-Item $tempJsonFile -Force -ErrorAction SilentlyContinue
exit 0
}
} else {
# Document doesn't exist - create new one
Write-Host "Creating new document..." -ForegroundColor Yellow
# Method 1: Try with temporary file
Write-Host "Attempting file-based creation..." -ForegroundColor Gray
$result = aws ssm create-document `
--name $DocumentName `
--document-type "Command" `
--content "file://$tempJsonFile" `
--document-format JSON `
--output json 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "File method failed, trying stdin approach..." -ForegroundColor Yellow
# Method 2: Use stdin approach (pipe JSON directly)
$result = $jsonString | aws ssm create-document `
--name $DocumentName `
--document-type "Command" `
--content "file://-" `
--document-format JSON `
--output json
}
if ($LASTEXITCODE -eq 0) {
Write-Host "✓ Document created successfully" -ForegroundColor Green
try {
$createInfo = $result | ConvertFrom-Json
Write-Host " Document Name: $($createInfo.DocumentDescription.Name)" -ForegroundColor Gray
Write-Host " Version: $($createInfo.DocumentDescription.DocumentVersion)" -ForegroundColor Gray
Write-Host " Status: $($createInfo.DocumentDescription.Status)" -ForegroundColor Gray
} catch {
Write-Host " Creation completed (details not available)" -ForegroundColor Gray
}
} else {
# Provide detailed error information
Write-Host "AWS CLI exit code: $LASTEXITCODE" -ForegroundColor Red
Write-Host "Error output: $result" -ForegroundColor Red
# Try one more approach - create a simple batch file to avoid PowerShell escaping issues
Write-Host "Trying batch file approach as last resort..." -ForegroundColor Yellow
$batchFile = ".\create-ssm-doc.bat"
$batchContent = @"
@echo off
aws ssm create-document --name "$DocumentName" --document-type "Command" --content "file://$tempJsonFile" --document-format JSON --output json
"@
$batchContent | Out-File -FilePath $batchFile -Encoding ASCII
$batchResult = & cmd /c $batchFile
$batchExitCode = $LASTEXITCODE
Remove-Item $batchFile -Force -ErrorAction SilentlyContinue
if ($batchExitCode -eq 0) {
Write-Host "✓ Document created successfully using batch approach" -ForegroundColor Green
} elseif ($batchExitCode -eq 254) {
# Handle duplicate content even during creation attempts
Write-Host "⚠️ Document already exists with identical content" -ForegroundColor Yellow
Write-Host "✓ Document '$DocumentName' is ready to use" -ForegroundColor Green
} else {
throw "All creation methods failed - AWS CLI error code: $batchExitCode"
}
}
}
# Configure document permissions for sharing within the account
# Only attempt if we had a successful operation (including duplicate content scenarios)
if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq 254) {
Write-Host "Setting document permissions..." -ForegroundColor Yellow
$permResult = aws ssm modify-document-permission `
--name $DocumentName `
--permission-type Share `
--account-ids-to-add "self" `
--output json 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host "✓ Document permissions set" -ForegroundColor Green
} else {
# Permission setting failure is not critical - document can still be used
Write-Host "⚠️ Could not set document permissions (this is usually OK)" -ForegroundColor Yellow
}
# Verify the document was created successfully
Write-Host "Verifying document creation..." -ForegroundColor Yellow
$verifyResult = aws ssm describe-document --name $DocumentName --output json 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Host "✓ Document verification successful" -ForegroundColor Green
try {
$docInfo = $verifyResult | ConvertFrom-Json
Write-Host " Status: $($docInfo.Document.Status)" -ForegroundColor Gray
Write-Host " Document Format: $($docInfo.Document.DocumentFormat)" -ForegroundColor Gray
} catch {
Write-Host " Document exists and is accessible" -ForegroundColor Gray
}
} else {
Write-Host "⚠️ Could not verify document (but creation may have succeeded)" -ForegroundColor Yellow
}
# Display success summary and usage instructions
Write-Host ""
Write-Host "=== SSM Document Ready ===" -ForegroundColor Green
Write-Host "Document Name: $DocumentName" -ForegroundColor White
Write-Host "You can now use this document with your scale deployment function" -ForegroundColor White
Write-Host ""
# Provide example usage for the newly created document
Write-Host "Example usage:" -ForegroundColor Yellow
Write-Host '$result = Deploy-CISConfigurationAtScale \' -ForegroundColor Gray
Write-Host " -DocumentName `"$DocumentName`" \" -ForegroundColor Gray
Write-Host ' -ConfigurationS3Bucket "your-bucket" \' -ForegroundColor Gray
Write-Host ' -ConfigurationS3Key "configs/production.mof" \' -ForegroundColor Gray
Write-Host ' -ConfigurationHash "your-mof-hash"' -ForegroundColor Gray
# Show how to test the document
Write-Host ""
Write-Host "To test the document:" -ForegroundColor Yellow
Write-Host "aws ssm list-documents --filters Key=Name,Values=$DocumentName" -ForegroundColor Gray
}
} catch {
# Handle errors gracefully with helpful troubleshooting information
Write-Host "❌ Failed to create SSM document: $($_.Exception.Message)" -ForegroundColor Red
Write-Verbose "Alternative manual approach:"
Write-Verbose "1. Copy the JSON content manually:"
Write-Verbose " Get-Content $JsonFilePath -Raw | Set-Clipboard"
Write-Verbose ""
Write-Verbose "2. Create document via AWS Console:"
Write-Verbose " - Go to Systems Manager > Documents"
Write-Verbose " - Click 'Create document'"
Write-Verbose " - Choose 'Command' document type"
Write-Verbose " - Paste JSON content"
Write-Verbose ""
Write-Verbose "3. Or try AWS CLI with reduced JSON:"
Write-Verbose " - The JSON might be too complex for your AWS CLI version"
Write-Verbose " - Try updating AWS CLI: pip install --upgrade awscli"
Write-Host "Run with -Verbose for troubleshooting steps" -ForegroundColor Yellow
exit 1
} finally {
# Clean up temporary files
@(".\ssm-temp-*.json", ".\create-ssm-doc.bat") | ForEach-Object {
Get-ChildItem -Path $_ -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
}
}
Next Steps and Preview
What We've Accomplished
Look at what you've built! You've gone from manual server hardening to a fully automated, scalable solution that:
- Deploys production-ready DSC configurations with proper error handling and logging
- Scales intelligently from one server to thousands without overwhelming your infrastructure
- Monitors compliance in real-time with CloudWatch dashboards and alerts
- Reduce Redundant Downloads through caching
You're no longer just applying security settings - you're running a compliance platform.
Key Takeaways
Before you rush off to implement this everywhere, remember these lessons:
- Verify Systems Manager connectivity first - I cannot stress this enough. If you skipped Part 2, issues will haunt you throughout deployment.
- Start small, scale gradually - Test on a few non-critical servers first. I learned this the hard way when I accidentally locked myself out of 50 servers (thank goodness for break-glass accounts).
- Monitor performance impact closely - DSC can be CPU-intensive. Schedule wisely and watch those CloudWatch metrics.
- Automate compliance reporting - Your auditors will love you for those automated reports showing 99%+ compliance.
- Plan for exceptions and conflicts - Every environment has quirks. Document your ExcludeList choices.
- Test your recovery procedures - Before you need them. Trust me on this one.
Coming in Part 4: Advanced Operations
Ready to take it to the next level? In Part 4, we'll cover:
- Handles real-world issues like timeouts, WinRM quotas, GPO conflicts, and Systems Manager connectivity
- Production best practices from organizations running this at scale
- Advanced automation with Lambda for self-healing infrastructure
- Multi-account strategies using AWS Organizations and Control Tower
- CI/CD integration to version control your security configurations
- Disaster recovery planning when (not if) something goes catastrophically wrong
Homework Before Part 4
Want to be ready for the advanced stuff? Here's your homework:
- Deploy to at least 10 instances - You'll start seeing patterns and issues that don't appear with just one or two servers.
- Set up your CloudWatch dashboard - Visualize your compliance status. Make it pretty enough to show management.
- Document your exclusion list - For each excluded control, document why. Future you will thank present you.
- Measure baseline performance metrics - How long does a full configuration take? What's the CPU impact? You'll need these numbers for capacity planning.
- Break something and fix it - Seriously. Intentionally cause a configuration drift and watch your automation fix it. It's oddly satisfying.
Resources and References
Here are the official docs you'll want to bookmark:
- AWS Systems Manager State Manager documentation
- AWS Systems Manager prerequisites
- PowerShell DSC documentation
- CIS Benchmarks download page
- AWS Systems Manager pricing
- CloudWatch Logs pricing
- VPC endpoints for Systems Manager
Ready to Scale Your Security?
You've got the tools, the knowledge, and hopefully the motivation to transform your Windows security posture. The question isn't whether you should automate your security configurations - it's how quickly you can get started.
Share your deployment experiences in the comments:
- What issues did you hit that I didn't cover?
- How many servers are you managing with this approach?
- What creative exclusions did you need for your environment?
And if you successfully deploy this to 100+ servers without any issues on your first try, please let me know your secret. I'll either be incredibly impressed or incredibly suspicious. 😉
Majority of code listed in this article can also be viewed at the companion GitHub.
See you in Part 4 where we'll push the boundaries of what's possible with DSC and Systems Manager!