이 샘플 스크립트를 복사하여 붙여넣고 환경에 필요한 대로 수정합니다.
<# . 시놉시스 배포가 완료될 때까지 실행되는 지속적인 보안 부팅 롤아웃 오케스트레이터입니다.
.DESCRIPTION 이 스크립트는 보안 부팅 인증서 롤아웃을 위한 전체 엔드 투 엔드 자동화를 제공합니다. 1. 집계 데이터를 기반으로 롤아웃 웨이브 생성 2. 각 웨이브에 대한 AD 그룹 및 GPO를 만듭니다. 3. 디바이스 업데이트 모니터(이벤트 1808) 4. 차단된 버킷(연결할 수 없는 디바이스)을 검색합니다. 5. 자동으로 다음 웨이브로 진행 6. 모든 적격 디바이스가 업데이트될 때까지 실행 완료 조건: - 디바이스가 남아 있지 않음: 작업 필요, 높은 신뢰도, 관찰, 일시적으로 일시 중지됨 - scope(의도적으로): 지원되지 않음, 보안 부팅 사용 안 함 - 완료될 때까지 연속 실행 - 임의 웨이브 제한 없음 출시 전략: - 높은 신뢰도: 첫 번째 웨이브의 모든 디바이스(안전) - 작업 필요: 프로그레시브 더블(1→2→4→8...) 차단 논리: - MaxWaitHours 후 오케스트레이터는 업데이트되지 않은 디바이스를 ping합니다. - 디바이스에 연결할 수 없는 경우(ping 실패) → 버킷이 조사를 위해 차단됨 - 디바이스에 연결할 수 있지만 업데이트되지 → 대기 중인 경우(다시 부팅해야 할 수 있음) - 차단된 버킷은 관리자가 차단을 해제할 때까지 제외됩니다. 자동 차단 해제: - 차단된 버킷의 디바이스가 나중에 업데이트됨으로 표시되는 경우(이벤트 1808) 버킷이 자동으로 차단 해제되고 롤아웃이 진행됩니다. - 일시적으로 오프라인 상태였지만 다시 돌아온 디바이스를 처리합니다. 디바이스 추적: - 호스트 이름으로 디바이스 추적(롤아웃 중에 이름이 변경되지 않는다고 가정) - 참고: JSON 컬렉션에는 고유한 컴퓨터 ID가 포함되지 않습니다. 더 나은 추적을 위해 하나 추가
.PARAMETER AggregationInputPath 원시 JSON 디바이스 데이터 경로(스크립트 검색)
.PARAMETER ReportBasePath 집계 보고서의 기본 경로
.PARAMETER TargetOU GPO를 연결할 OU의 고유 이름입니다.선택 사항 - 지정하지 않은 경우 GPO는 도메인 전체 범위에 대한 도메인 루트에 연결됩니다.보안 그룹 필터링은 대상 디바이스만 정책을 수신하도록 합니다.
.PARAMETER MaxWaitHours 연결 가능성을 확인하기 전에 디바이스가 업데이트되기를 기다리는 시간입니다.이 시간 후에는 업데이트되지 않은 디바이스가 ping됩니다.연결할 수 없는 디바이스로 인해 버킷이 차단됩니다.기본값: 72(3일)
.PARAMETER PollIntervalMinutes 상태 확인 사이의 분입니다. 기본값: 1440(1일)
.PARAMETER AllowListPath 롤아웃 허용(대상 롤아웃)에 대한 호스트 이름을 포함하는 파일의 경로입니다..txt(줄당 하나의 호스트 이름) 또는 .csv 지원합니다(Hostname/ComputerName/Name 열 포함).지정하면 이러한 디바이스만 롤아웃에 포함됩니다.BlockList는 AllowList 후에도 계속 적용됩니다.
.PARAMETER AllowADGroup 허용할 컴퓨터 계정을 포함하는 AD 보안 그룹의 이름입니다.예: "SecureBoot-Pilot-Computers" 또는 "Wave1-Devices" 지정하면 이 그룹의 디바이스만 롤아웃에 포함됩니다.파일 및 AD 기반 대상 지정 모두에 대해 AllowListPath와 결합합니다.
.PARAMETER ExclusionListPath 롤아웃에서 제외할 호스트 이름이 포함된 파일의 경로입니다(VIP/이그제큐티브 디바이스)..txt(줄당 하나의 호스트 이름) 또는 .csv 지원합니다(Hostname/ComputerName/Name 열 포함).이러한 디바이스는 롤아웃 웨이브에 포함되지 않습니다.BlockList는 AllowList 필터링 후에 적용됩니다. . PARAMETER ExcludeADGroup 제외할 컴퓨터 계정을 포함하는 AD 보안 그룹의 이름입니다.예: "VIP-컴퓨터" 또는 "Executive-Devices" 파일 및 AD 기반 제외 모두에 대해 ExclusionListPath와 결합합니다.
.PARAMETER UseWinCS GPO/AvailableUpdatesPolicy 대신 WinCS(Windows 구성 시스템)를 사용합니다.WinCS는 각 엔드포인트에서 직접 WinCsFlags.exe 실행하여 보안 부팅 사용을 배포합니다.WinCsFlags.exe 예약된 작업을 통해 시스템 컨텍스트에서 실행됩니다.이 메서드는 다음에 유용합니다. - 더 빠른 롤아웃(즉각적인 효과 및 GPO 처리 대기) - 도메인에 가입되지 않은 디바이스 - AD/GPO 인프라가 없는 환경 참조: https://support.microsoft.com/en-us/topic/windows-configuration-system-wincs-apis-for-secure-boot-d3e64aa0-6095-4f8a-b8e4-fbfda254a8fe
.PARAMETER WinCSKey 보안 부팅 활성화에 사용할 WinCS 키입니다.기본값: F33E0C8E002 이 키는 보안 부팅 롤아웃 구성에 해당합니다. . PARAMETER DryRun 변경하지 않고 수행할 작업 표시
.PARAMETER ListBlockedBuckets 현재 차단된 모든 버킷 표시 및 종료
.PARAMETER UnblockBucket 키로 특정 버킷 차단 해제 및 종료
.PARAMETER UnblockAll 모든 버킷 차단 해제 및 종료
.PARAMETER EnableTaskOnDisabled 예약된 작업이 비활성화된 모든 디바이스에 Enable-SecureBootUpdateTask.ps1 배포합니다.-Quiet를 사용하여 스크립트 사용 옵션을 실행하는 일회성 예약 작업으로 GPO를 만듭니다.이는 Secure-Boot-Update 작업을 사용하지 않도록 설정된 디바이스를 수정하는 데 유용합니다.
.EXAMPLE .\Start-SecureBootRolloutOrchestrator.ps1 ' -AggregationInputPath "\\server\SecureBootLogs$\Json" ' -ReportBasePath "E:\SecureBootReports" ' -TargetOU "OU=Workstations,DC=contoso,DC=com"
.EXAMPLE # 차단된 버킷 나열 .\Start-SecureBootRolloutOrchestrator.ps1 -ReportBasePath "E:\SecureBootReports" -ListBlockedBuckets
.EXAMPLE # 특정 버킷 차단 해제 .\Start-SecureBootRolloutOrchestrator.ps1 -ReportBasePath "E:\SecureBootReports" -UnblockBucket "Dell_Latitude5520_BIOS1.2.3"
.EXAMPLE # 텍스트 파일을 사용하여 롤아웃에서 VIP 디바이스 제외 .\Start-SecureBootRolloutOrchestrator.ps1 ' -AggregationInputPath "\\server\SecureBootLogs$\Json" ' -ReportBasePath "E:\SecureBootReports" ' -ExclusionListPath "C:\Admin\VIP-Devices.txt"
.EXAMPLE # AD 보안 그룹의 디바이스 제외(예: 이그제큐티브 랩톱) .\Start-SecureBootRolloutOrchestrator.ps1 ' -AggregationInputPath "\\server\SecureBootLogs$\Json" ' -ReportBasePath "E:\SecureBootReports" ' -ExcludeADGroup "VIP-Computers"
.EXAMPLE # GPO/AvailableUpdatesPolicy 대신 WinCS(Windows 구성 시스템) 사용 # WinCsFlags.exe 예약된 작업을 통해 각 엔드포인트의 SYSTEM 컨텍스트에서 실행됩니다. .\Start-SecureBootRolloutOrchestrator.ps1 ' -AggregationInputPath "\\server\SecureBootLogs$\Json" ' -ReportBasePath "E:\SecureBootReports" ' -UseWinCS ' -WinCSKey "F33E0C8E002" #>
[CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$AggregationInputPath, [Parameter(Mandatory = $false)] [string]$ReportBasePath, [Parameter(Mandatory = $false)] [string]$TargetOU, [Parameter(Mandatory = $false)] [string]$WavePrefix = "SecureBoot-Rollout", [Parameter(Mandatory = $false)] [int]$MaxWaitHours = 72, [Parameter(Mandatory = $false)] [int]$PollIntervalMinutes = 1440,
[Parameter(Mandatory = $false)] [int]$ProcessingBatchSize = 5000,
[Parameter(Mandatory = $false)] [int]$DeviceLogSampleSize = 25,
[Parameter(Mandatory = $false)] [switch]$LargeScaleMode, # ============================================================================ # AllowList/BlockList 매개 변수 # ============================================================================ # AllowList = 이러한 디바이스만 포함(대상 롤아웃) # BlockList = 이러한 디바이스 제외(롤아웃되지 않음) # 처리 순서: AllowList 먼저(지정된 경우) 다음 BlockList [Parameter(Mandatory = $false)] [string]$AllowListPath [Parameter(Mandatory = $false)] [string]$AllowADGroup [Parameter(Mandatory = $false)] [string]$ExclusionListPath [Parameter(Mandatory = $false)] [string]$ExcludeADGroup # ============================================================================ # WinCS(Windows 구성 시스템) 매개 변수 # ============================================================================ # WinCS는 AvailableUpdatesPolicy GPO 배포의 대안입니다. # 각 엔드포인트에서 WinCsFlags.exe 사용하여 보안 부팅 롤아웃을 사용하도록 설정합니다.# WinCsFlags.exe 엔드포인트의 SYSTEM 컨텍스트에서 실행됩니다.# 참조: https://support.microsoft.com/en-us/topic/windows-configuration-system-wincs-apis-for-secure-boot-d3e64aa0-6095-4f8a-b8e4-fbfda254a8fe [Parameter(Mandatory = $false)] [switch]$UseWinCS [Parameter(Mandatory = $false)] [string]$WinCSKey = "F33E0C8E002", [Parameter(Mandatory = $false)] [switch]$DryRun, [Parameter(Mandatory = $false)] [switch]$ListBlockedBuckets, [Parameter(Mandatory = $false)] [string]$UnblockBucket [Parameter(Mandatory = $false)] [switch]$UnblockAll, [Parameter(Mandatory = $false)] [switch]$EnableTaskOnDisabled )
$ErrorActionPreference = "Stop" $ScriptRoot = $PSScriptRoot $DownloadUrl = "https://aka.ms/getsecureboot" $DownloadSubPage = "배포 및 모니터링 샘플"
# ============================================================================ # 종속성 유효성 검사 # ============================================================================
function Test-ScriptDependencies { param( [Parameter(Mandatory = $true)] [string]$ScriptDirectory [Parameter(Mandatory = $true)] [string[]]$RequiredScripts ) $missingScripts = @() foreach($RequiredScripts $script) { $scriptPath = Join-Path $ScriptDirectory $script if (-not (Test-Path $scriptPath)) { $missingScripts += $script } } if ($missingScripts.Count -gt 0) { Write-Host "" Write-Host ("=" * 70) -ForegroundColor Red Write-Host "누락된 종속성" -ForegroundColor Red Write-Host ("=" * 70) -ForegroundColor Red Write-Host "" Write-Host "다음 필수 스크립트를 찾을 수 없습니다." -ForegroundColor 노란색 foreach($missingScripts $script) { Write-Host " - $script" -ForegroundColor White } Write-Host "" Write-Host "에서 최신 스크립트를 다운로드하세요:" -ForegroundColor Cyan Write-Host " URL: $DownloadUrl" -ForegroundColor White Write-Host "탐색: '$DownloadSubPage'" -ForegroundColor White Write-Host "" Write-Host "모든 스크립트를 동일한 디렉터리에 추출하고 다시 실행합니다." -ForegroundColor Yellow Write-Host "" return $false } return $true }
# Required scripts for orchestrator $requiredScripts = @( "Aggregate-SecureBootData.ps1", "Enable-SecureBootUpdateTask.ps1", "Deploy-GPO-SecureBootCollection.ps1", "Detect-SecureBootCertUpdateStatus.ps1" )
if (-not (Test-ScriptDependencies -ScriptDirectory $PSScriptRoot -RequiredScripts $requiredScripts)) { 종료 1 }
# ============================================================================ # 매개 변수 유효성 검사 # ============================================================================
# Admin commands only need ReportBasePath $isAdminCommand = $ListBlockedBuckets 또는 $UnblockBucket 또는 $UnblockAll 또는 $EnableTaskOnDisabled
if (-not $ReportBasePath) { Write-Host "ERROR: -ReportBasePath가 필요합니다." -ForegroundColor Red 종료 1 }
if (-not $isAdminCommand -and -not $AggregationInputPath) { Write-Host "ERROR: -AggregationInputPath는 롤아웃에 필요합니다(-ListBlockedBuckets, -UnblockBucket, -UnblockAll에 필요하지 않음)" -ForegroundColor Red 종료 1 }
# ============================================================================ # GPO 검색 - 검색 GPO 확인 # ============================================================================
if (-not $isAdminCommand -and -not $DryRun) { $CollectionGPOName = "SecureBoot-EventCollection" # GroupPolicy 모듈을 사용할 수 있는지 확인 if (Get-Module -ListAvailable -Name GroupPolicy) { Import-Module GroupPolicy -ErrorAction SilentlyContinue Write-Host "검색 GPO 확인..." -ForegroundColor 노란색 try { # GPO가 있는지 확인 $existingGpo = Get-GPO -Name $CollectionGPOName -ErrorAction SilentlyContinue if ($existingGpo) { Write-Host " 검색 GPO 발견: $CollectionGPOName" -ForegroundColor 녹색 } else { Write-Host "" Write-Host ("=" * 70) -ForegroundColor Yellow Write-Host "경고: 검색 GPO를 찾을 수 없음" -ForegroundColor 노란색 Write-Host ("=" * 70) -ForegroundColor Yellow Write-Host "" Write-Host "검색 GPO '$CollectionGPOName'을(를) 찾을 수 없습니다." -ForegroundColor 노란색 Write-Host "이 GPO가 없으면 디바이스 데이터가 수집되지 않습니다." -ForegroundColor Yellow Write-Host "" Write-Host "검색 GPO를 배포하려면 run:" -ForegroundColor Cyan Write-Host " .\Deploy-GPO-SecureBootCollection.ps1 -DomainName <domain> -AutoDetectOU" -ForegroundColor White Write-Host "" Write-Host "어쨌든 계속하시겠습니까? (Y/N)" -ForegroundColor Yellow $response = 읽기 호스트 if ($response -notmatch '^[Yy]') { Write-Host "중단. 검색 GPO를 먼저 배포합니다." -ForegroundColor Red 종료 1 } } } catch { Write-Host " GPO에 대해 검사 수 없습니다. $($_) Exception.Message)" -ForegroundColor Yellow } } else { Write-Host " GroupPolicy 모듈을 사용할 수 없음 - GPO 검사 건너뛰기" -ForegroundColor Gray } Write-Host "" }
# ============================================================================ # 상태 파일 경로 # ============================================================================
$stateDir = Join-Path $ReportBasePath "RolloutState" if (-not (Test-Path $stateDir)) { New-Item -ItemType 디렉터리 -경로 $stateDir -Force | Out-Null }
$rolloutStatePath = Join-Path $stateDir "RolloutState.json" $blockedBucketsPath = Join-Path $stateDir "BlockedBuckets.json" $adminApprovedPath = Join-Path $stateDir "AdminApprovedBuckets.json" $deviceHistoryPath = Join-Path $stateDir "DeviceHistory.json" $processingCheckpointPath = Join-Path $stateDir "ProcessingCheckpoint.json"
# ============================================================================ # PS 5.1 호환성: ConvertTo-Hashtable # ============================================================================ # ConvertFrom-Json -AsHashtable은 PS7+ 전용입니다. 이렇게 하면 호환성이 제공됩니다.
function ConvertTo-Hashtable { param( [Parameter(ValueFromPipeline = $true)] $InputObject ) 프로세스 { if ($null -eq $InputObject) { return @{} } if ($InputObject -is [System.Collections.IDictionary]) { return $InputObject } if ($InputObject -is [PSCustomObject]) { # 일관된 키 순서 지정 및 안전한 중복 처리에 [ordered] 사용 $hash = [ordered]@{} foreach($InputObject.PSObject.Properties의 $prop) { # 인덱싱된 할당은 덮어쓰기를 통해 중복 항목을 안전하게 처리합니다. $hash[$prop. Name] = ConvertTo-Hashtable $prop. 값 } return $hash } if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { return @($InputObject | ForEach-Object { ConvertTo-Hashtable $_ }) } return $InputObject } }
# ============================================================================ # ADMIN 명령: 버킷 나열/차단 해제 # ============================================================================
if ($ListBlockedBuckets) { Write-Host "" Write-Host ("=" * 80) -ForegroundColor Yellow Write-Host "차단된 버킷" -전경색 노란색 Write-Host ("=" * 80) -ForegroundColor Yellow Write-Host "" if (Test-Path $blockedBucketsPath) { $blocked = Get-Content $blockedBucketsPath -Raw | ConvertFrom-Json | ConvertTo-Hashtable if($blocked. Count -eq 0) { Write-Host "차단된 버킷 없음." -ForegroundColor Green } else { Write-Host "차단된 총액: $($blocked. Count)" -ForegroundColor Red Write-Host "" foreach($blocked $key. 키) { $info = $blocked[$key] Write-Host "버킷: $key" -ForegroundColor Red Write-Host " 차단됨: $($info. BlockedAt)" -ForegroundColor Gray Write-Host " 이유: $($info. Reason)" -ForegroundColor Gray Write-Host " 실패한 디바이스: $($info. FailedDevice)" -ForegroundColor Gray Write-Host " 마지막으로 보고됨: $($info. LastReported)" -ForegroundColor Gray Write-Host " 웨이브: $($info. WaveNumber)" -ForegroundColor Gray Write-Host " 버킷의 디바이스: $($info. DevicesInBucket)" -ForegroundColor Gray Write-Host "" } Write-Host "버킷 차단을 해제하려면:" Write-Host " .\Start-SecureBootRolloutOrchestrator.ps1 -ReportBasePath '$ReportBasePath' -UnblockBucket 'BUCKET_KEY'" -ForegroundColor Cyan Write-Host "" Write-Host "모두 차단을 해제하려면:" Write-Host " .\Start-SecureBootRolloutOrchestrator.ps1 -ReportBasePath '$ReportBasePath' -UnblockAll" -ForegroundColor Cyan } } else { Write-Host "차단된 버킷 파일을 찾을 수 없습니다." -ForegroundColor 녹색 } Write-Host "" 종료 0 }
if ($UnblockBucket) { Write-Host "" if (Test-Path $blockedBucketsPath) { $blocked = Get-Content $blockedBucketsPath -Raw | ConvertFrom-Json | ConvertTo-Hashtable if($blocked. Contains($UnblockBucket)) { $blocked. Remove($UnblockBucket) $blocked | ConvertTo-Json -Depth 10 | Out-File $blockedBucketsPath -인코딩 UTF8 -Force # 다시 차단을 방지하기 위해 관리자 승인 목록에 추가 $adminApproved = @{} if (Test-Path $adminApprovedPath) { $adminApproved = Get-Content $adminApprovedPath -Raw | ConvertFrom-Json | ConvertTo-Hashtable } $adminApproved[$UnblockBucket] = @{ ApprovedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss" ApprovedBy = $env:USERNAME } $adminApproved | ConvertTo-Json -Depth 10 | Out-File $adminApprovedPath -인코딩 UTF8 -Force Write-Host "차단 해제 버킷: $UnblockBucket" -ForegroundColor 녹색 Write-Host "관리자 승인 목록에 추가됨(자동으로 다시 차단되지 않음)" -ForegroundColor Cyan } else { Write-Host "버킷을 찾을 수 없음: $UnblockBucket" -ForegroundColor 노란색 Write-Host "사용 가능한 버킷:" -ForegroundColor 회색 $blocked. 키 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray } } } else { Write-Host "차단된 버킷 파일을 찾을 수 없습니다." -ForegroundColor 노란색 } Write-Host "" 종료 0 }
if ($UnblockAll) { Write-Host "" if (Test-Path $blockedBucketsPath) { $blocked = Get-Content $blockedBucketsPath -Raw | ConvertFrom-Json | ConvertTo-Hashtable $count = $blocked. 횟수 @{} | ConvertTo-Json | Out-File $blockedBucketsPath -ENcoding UTF8 -Force Write-Host "모든 $count 버킷 차단 해제." -ForegroundColor 녹색 } else { Write-Host "차단된 버킷 파일을 찾을 수 없습니다." -ForegroundColor 노란색 } Write-Host "" 종료 0 }
# ============================================================================ # 도우미 함수 # ============================================================================
function Get-RolloutState { if (Test-Path $rolloutStatePath) { try { $loaded = Get-Content $rolloutStatePath -Raw | ConvertFrom-Json | ConvertTo-Hashtable # 필수 속성이 있는지 확인 if ($null -eq $loaded. CurrentWave) { throw "잘못된 상태 파일 - CurrentWave 누락" } # WaveHistory가 항상 배열인지 확인합니다(PS5.1 JSON 역직렬화 수정). if ($null -eq $loaded. WaveHistory) { $loaded. WaveHistory = @() } elseif($loaded. WaveHistory -isnot [array]) { $loaded. WaveHistory = @($loaded. WaveHistory) } return $loaded } catch { Write-Log "손상된 RolloutState.json 검색됨: $($_. Exception.Message)" "WARN" Write-Log "손상된 파일 백업 및 새로 시작" "WARN" $backupPath = "$rolloutStatePath.corrupted.$(Get-Date -Format 'yyyyMMdd-HHmmss')" Move-Item $rolloutStatePath $backupPath -Force -ErrorAction SilentlyContinue } } return @{ CurrentWave = 0 StartedAt = $null LastAggregation = $null TotalDevicesTargeted = 0 TotalDevicesUpdated = 0 Status = "NotStarted" WaveHistory = @() } }
function Save-RolloutState { param($State) $State | ConvertTo-Json -Depth 10 | Out-File $rolloutStatePath -인코딩 UTF8 -Force }
function Get-WeekdayProjection { <# . 시놉시스 주말의 예상 완료 날짜 계산(토/일의 진행 상황 없음) #> param( [int]$RemainingDevices, [double]$DevicesPerDay [datetime]$StartDate = (Get-Date) ) if ($DevicesPerDay -le 0 -또는 $RemainingDevices -le 0) { return @{ ProjectedDate = $null WorkingDaysNeeded = 0 CalendarDaysNeeded = 0 } } # 필요한 작업일 계산(주말 제외) $workingDaysNeeded = [math]::Ceiling($RemainingDevices / $DevicesPerDay) # 근무일을 달력 일로 변환(주말 추가) $currentDate = $StartDate.Date $daysAdded = 0 $workingDaysAdded = 0 while ($workingDaysAdded -lt $workingDaysNeeded) { $currentDate = $currentDate.AddDays(1) $daysAdded++ # 평일만 계산 if ($currentDate.DayOfWeek -ne [DayOfWeek]::saturday -and $currentDate.DayOfWeek -ne [DayOfWeek]::Sunday) { $workingDaysAdded++ } } return @{ ProjectedDate = $currentDate.ToString("yyyy-MM-dd") WorkingDaysNeeded = $workingDaysNeeded CalendarDaysNeeded = $daysAdded } }
function Save-RolloutSummary { <# . 시놉시스 dashboard 표시에 대한 프로젝션 정보를 사용하여 롤아웃 요약 저장 #> param( [hashtable]$State, [int]$TotalDevices, [int]$UpdatedDevices, [int]$NotUpdatedDevices, [double]$DevicesPerDay ) $summaryPath = Join-Path $stateDir "SecureBootRolloutSummary.json" # 주말 인식 프로젝션 계산 $projection = Get-WeekdayProjection -RemainingDevices $NotUpdatedDevices -DevicesPerDay $DevicesPerDay $summary = @{ GeneratedAt = (Get-Date -Format "yyyy-MM-dd HH:mm:ss") RolloutStartDate = $State.StartedAt LastAggregation = $State.LastAggregation CurrentWave = $State.CurrentWave 상태 = $State.Status # 디바이스 수 TotalDevices = $TotalDevices UpdatedDevices = $UpdatedDevices NotUpdatedDevices = $NotUpdatedDevices PercentUpdated = if ($TotalDevices -gt 0) { [math]::Round(($UpdatedDevices / $TotalDevices) * 100, 1) } else { 0 } # 속도 메트릭 DevicesPerDay = [math]::Round($DevicesPerDay, 1) TotalDevicesTargeted = $State.TotalDevicesTargeted TotalWaves = $State.CurrentWave # 주말 인식 프로젝션 ProjectedCompletionDate = $projection. ProjectedDate WorkingDaysRemaining = $projection. WorkingDaysNeeded CalendarDaysRemaining = $projection. CalendarDaysNeeded # 주말 제외에 대한 참고 사항 ProjectionNote = "예상 완료는 주말(토/일)을 제외합니다." } $summary | ConvertTo-Json -Depth 5 | Out-File $summaryPath -Encoding UTF8 -Force Write-Log "롤아웃 요약 저장됨: $summaryPath" "INFO" return $summary }
function Get-BlockedBuckets { if (Test-Path $blockedBucketsPath) { return Get-Content $blockedBucketsPath -Raw | ConvertFrom-Json | ConvertTo-Hashtable } return @{} }
function Save-BlockedBuckets { param($Blocked) $Blocked | ConvertTo-Json -Depth 10 | Out-File $blockedBucketsPath -인코딩 UTF8 -Force }
function Get-AdminApproved { if (Test-Path $adminApprovedPath) { return Get-Content $adminApprovedPath -Raw | ConvertFrom-Json | ConvertTo-Hashtable } return @{} }
function Get-DeviceHistory { if (Test-Path $deviceHistoryPath) { return Get-Content $deviceHistoryPath -Raw | ConvertFrom-Json | ConvertTo-Hashtable } return @{} }
function Save-DeviceHistory { param($History) $History | ConvertTo-Json -Depth 10 | Out-File $deviceHistoryPath -인코딩 UTF8 -Force }
function Save-ProcessingCheckpoint { param( [string]$Stage, [int]$Processed, [int]$Total, [hashtable]$Metrics = @{} )
$checkpoint = @{ 스테이지 = $Stage UpdatedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss" 처리됨 = $Processed 합계 = $Total Percent = if ($Total -gt 0) { [math]::Round(($Processed / $Total) * 100, 2) } else { 0 } 메트릭 = $Metrics }
$checkpoint | ConvertTo-Json -Depth 6 | Out-File $processingCheckpointPath -Encoding UTF8 -Force }
function Get-NotUpdatedIndexes { param([array]$Devices)
$hostSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $bucketCounts = @{}
foreach ($device in $Devices) { $hostname = if($device. 호스트 이름) { $device. 호스트 이름 } elseif($device. HostName) { $device. HostName } else { $null } if ($hostname) { [void]$hostSet.Add($hostname) }
$bucketKey = Get-BucketKey $device if ($bucketKey) { if ($bucketCounts.ContainsKey($bucketKey)) { $bucketCounts[$bucketKey]++ } else { $bucketCounts[$bucketKey] = 1 } } }
return @{ HostSet = $hostSet BucketCounts = $bucketCounts } }
function Write-Log { param([string]$Message, [string]$Level = "INFO") $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $color = 스위치($Level) { "OK" { "Green" } "WARN" { "Yellow" } "ERROR" { "Red" } "BLOCKED" { "DarkRed" } "WAVE" { "Cyan" } default { "White" } } Write-Host "[$timestamp] [$Level] $Message" -ForegroundColor $color # 또한 파일에 로그온 $logFile = Join-Path $stateDir "Orchestrator_$(Get-Date -Format 'yyyyMMdd').log" "[$timestamp] [$Level] $Message" | Out-File $logFile -Append -Encoding UTF8 }
function Get-BucketKey { param($Device) # 사용 가능한 경우 디바이스 JSON에서 BucketId 사용(검색 스크립트의 SHA256 해시) if ($Device.BucketId -and "$($Device.BucketId)" -ne "") { return "$($Device.BucketId)" } # 대체: 제조업체에서 생성|모델|bios $mfr = if ($Device.WMI_Manufacturer) { $Device.WMI_Manufacturer } else { $Device.Manufacturer } $model = if ($Device.WMI_Model) { $Device.WMI_Model } else { $Device.Model } $bios = if ($Device.BIOSDescription) { $Device.BIOSDescription } else { $Device.BIOS } 반환 "$mfr|$model|$bios" }
# ============================================================================ # VIP/제외 목록 로드 # ============================================================================
function Get-ExcludedHostnames { param( [string]$ExclusionFilePath [string]$ADGroupName ) $excluded = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) # 파일에서 로드(.txt 또는 .csv 지원) if ($ExclusionFilePath -and (Test-Path $ExclusionFilePath)) { $extension = [System.IO.Path]::GetExtension($ExclusionFilePath). ToLower() if ($extension -eq ".csv") { # CSV 형식: 'Hostname' 또는 'ComputerName' 열을 예상합니다. $csvData = Import-Csv $ExclusionFilePath $hostCol = if ($csvData[0]. PSObject.Properties.Name -contains 'Hostname') { 'Hostname' } elseif($csvData[0]. PSObject.Properties.Name -contains 'ComputerName') { 'ComputerName' } elseif($csvData[0]. PSObject.Properties.Name -contains 'Name') { 'Name' } else { $null } if ($hostCol) { foreach($csvData $row) { if (![ string]::IsNullOrWhiteSpace($row.$hostCol)) { [void]$excluded. Add($row.$hostCol.Trim()) } } } } else { # 일반 텍스트: 줄당 하나의 호스트 이름 Get-Content $ExclusionFilePath | ForEach-Object { $line = $_. Trim() if ($line 및 -not $line. StartsWith('#')) { [void]$excluded. Add($line) } } } Write-Log "로드된 $($excluded. Count) 제외 파일의 호스트 이름: $ExclusionFilePath" "INFO" } # AD 보안 그룹에서 로드 if ($ADGroupName) { try { $groupMembers = Get-ADGroupMember -IDENTITY $ADGroupName -Recursive -ErrorAction Stop | Where-Object { $_.objectClass -eq 'computer' } foreach($groupMembers $member) { [void]$excluded. Add($member. 이름) } Write-Log "AD 그룹에서 로드된 $($groupMembers.Count) 컴퓨터: $ADGroupName" "INFO" } catch { Write-Log "AD 그룹 '$ADGroupName'을 로드할 수 없음: $_" "WARN" } } return @($excluded) }
# ============================================================================ # ALLOW LIST LOADING(Targeted Rollout) # ============================================================================
function Get-AllowedHostnames { <# . 시놉시스 대상 롤아웃을 위해 AllowList 파일 및/또는 AD 그룹에서 호스트 이름을 로드합니다.AllowList를 지정하면 이러한 디바이스만 롤아웃에 포함됩니다.#> param( [string]$AllowFilePath [string]$ADGroupName ) $allowed = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) # 파일에서 로드(.txt 또는 .csv 지원) if ($AllowFilePath -and (Test-Path $AllowFilePath)) { $extension = [System.IO.Path]::GetExtension($AllowFilePath). ToLower() if ($extension -eq ".csv") { # CSV 형식: 'Hostname' 또는 'ComputerName' 열을 예상합니다. $csvData = Import-Csv $AllowFilePath if ($csvData.Count -gt 0) { $hostCol = if ($csvData[0]. PSObject.Properties.Name -contains 'Hostname') { 'Hostname' } elseif($csvData[0]. PSObject.Properties.Name -contains 'ComputerName') { 'ComputerName' } elseif($csvData[0]. PSObject.Properties.Name -contains 'Name') { 'Name' } else { $null } if ($hostCol) { foreach($csvData $row) { if (![ string]::IsNullOrWhiteSpace($row.$hostCol)) { [void]$allowed. Add($row.$hostCol.Trim()) } } } } } else { # 일반 텍스트: 줄당 하나의 호스트 이름 Get-Content $AllowFilePath | ForEach-Object { $line = $_. Trim() if ($line 및 -not $line. StartsWith('#')) { [void]$allowed. Add($line) } } } Write-Log "로드된 $($allowed. Count) 허용 목록 파일의 호스트 이름: $AllowFilePath" "INFO" } # AD 보안 그룹에서 로드 if ($ADGroupName) { try { $groupMembers = Get-ADGroupMember -IDENTITY $ADGroupName -Recursive -ErrorAction Stop | Where-Object { $_.objectClass -eq 'computer' } foreach($groupMembers $member) { [void]$allowed. Add($member. 이름) } Write-Log "AD 허용 그룹에서 로드된 $($groupMembers.Count) 컴퓨터: $ADGroupName" "INFO" } catch { Write-Log "AD 그룹 '$ADGroupName'을 로드할 수 없음: $_" "WARN" } } return @($allowed) }
# ============================================================================ # 데이터 새로 고침 및 모니터링 # ============================================================================
function Get-DataFreshness { <# . 시놉시스 JSON 파일 타임스탬프를 검사하여 검색 데이터의 최신 상태를 확인합니다.엔드포인트가 마지막으로 보고된 시기에 대한 통계를 반환합니다.#> param([string]$JsonPath) $jsonFiles = Get-ChildItem -Path $JsonPath -Filter "*.json" -ErrorAction SilentlyContinue if ($jsonFiles.Count -eq 0) { return @{ TotalFiles = 0 FreshFiles = 0 StaleFiles = 0 NoDataFiles = 0 OldestFile = $null NewestFile = $null AvgAgeHours = 0 경고 = "JSON 파일을 찾을 수 없음 - 검색이 배포되지 않을 수 있음" } } $now = Get-Date $freshThresholdHours = 24 # 지난 24시간 동안 업데이트된 Files "최신" $staleThresholdHours = 72 # 72시간보다 오래된 Files "부실"입니다. $fresh = 0 $stale = 0 $ages = @() foreach($jsonFiles $file) { $ageHours = ($now - $file. LastWriteTime). TotalHours $ages += $ageHours if ($ageHours -le $freshThresholdHours) { $fresh++ } elseif ($ageHours -ge $staleThresholdHours) { $stale++ } } $oldestFile = $jsonFiles | lastWriteTime Sort-Object | Select-Object -First 1 $newestFile = $jsonFiles | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $warning = $null if ($stale -gt($jsonFiles.Count * 0.5)) { $warning = "디바이스의 50% 이상에 부실 데이터(>72시간) - 검사 검색 GPO가 있습니다." } elseif($fresh -lt($jsonFiles.Count * 0.3)) { $warning = "최근에 보고된 디바이스의 30% 미만 - 검색이 실행되지 않을 수 있음" } return @{ TotalFiles = $jsonFiles.Count FreshFiles = $fresh StaleFiles = $stale MediumFiles = $jsonFiles.Count - $fresh - $stale OldestFile = $oldestFile.LastWriteTime NewestFile = $newestFile.LastWriteTime AvgAgeHours = [math]::Round(($ages | Measure-Object -Average). 평균, 1) 경고 = $warning } }
function Test-DetectionGPODeployed { <# . 시놉시스 검색/모니터링 인프라가 있는지 확인합니다.#> param([string]$JsonPath) # 확인 1: JSON 경로가 있음 if (-not (Test-Path $JsonPath)) { return @{ IsDeployed = $false 메시지 = "JSON 입력 경로가 없습니다. $JsonPath" } } # 확인 2: 적어도 일부 JSON 파일이 있음 $jsonCount = (Get-ChildItem -Path $JsonPath -Filter "*.json" -ErrorAction SilentlyContinue). 횟수 if ($jsonCount -eq 0) { return @{ IsDeployed = $false 메시지 = "$JsonPath JSON 파일 없음 - 검색 GPO가 배포되지 않았거나 디바이스가 아직 보고되지 않았습니다." } } # 확인 3: Files 합리적으로 최근 (적어도 지난 주에 일부) $recentFiles = Get-ChildItem -Path $JsonPath -Filter "*.json" -ErrorAction SilentlyContinue | Where-Object { $_. LastWriteTime -gt(Get-Date). AddDays(-7) } if ($recentFiles.Count -eq 0) { return @{ IsDeployed = $false 메시지 = "지난 7일 동안 업데이트된 JSON 파일 없음 - 검색 GPO가 손상되거나 디바이스가 오프라인 상태일 수 있음" } } return @{ IsDeployed = $true 메시지 = "검색이 활성으로 표시됩니다. $jsonCount 파일, 최근에 업데이트된 $($recentFiles.Count) " FileCount = $jsonCount RecentCount = $recentFiles.Count } }
# ============================================================================ # 디바이스 추적(HOSTNAME 기준) # ============================================================================
function Update-DeviceHistory { <# . 시놉시스 고유한 컴퓨터 식별자가 없으므로 호스트 이름으로 디바이스를 추적합니다.참고: BucketId는 일대다(동일한 하드웨어 구성 = 동일한 버킷)입니다.고유 식별자가 JSON 컬렉션에 추가되면 이 함수를 업데이트합니다.#> param( [array]$CurrentDevices, [hashtable]$DeviceHistory ) foreach($CurrentDevices $device) { $hostname = $device. 호스트 if (-not $hostname) { continue } # 호스트 이름으로 디바이스 추적 $DeviceHistory[$hostname] = @{ 호스트 이름 = $hostname BucketId = $device. BucketId 제조업체 = $device. WMI_Manufacturer 모델 = $device. WMI_Model LastSeen = Get-Date -Format "yyyy-MM-dd HH:mm:ss" 상태 = $device. UpdateStatus } } }
# ============================================================================ # 차단된 버킷 검색(디바이스 연결성 기반) # ============================================================================
<# . 설명 차단 논리: - 버킷은 다음과 같은 경우에만 차단됩니다. 1. 디바이스가 웨이브를 대상으로 했습니다. 2. 파도가 시작된 이래로 MaxWaitHours가 지났습니다. 3. 디바이스에 연결할 수 없음(ping 실패) - 디바이스에 연결할 수 있지만 아직 업데이트되지 않은 경우 계속 대기합니다. (업데이트가 다시 부팅 보류 중일 수 있음 - 이벤트 1808은 다시 부팅한 후에만 발생) - 연결할 수 없는 디바이스는 문제가 발생하여 조사가 필요했음을 나타냅니다. 차단을 해제: - -ListBlockedBuckets를 사용하여 차단된 버킷 보기 - -UnblockBucket "BucketKey"를 사용하여 특정 버킷 차단 해제 - -UnblockAll을 사용하여 모든 버킷 차단 해제 #>
function Test-DeviceReachable { param( [string]$Hostname, [string]$DataPath # 디바이스 JSON 파일 경로 ) # 메서드 1: JSON 파일 타임스탬프 확인(가장 빠른 - 파일 구문 분석 필요 없음) # 검색 스크립트가 최근에 실행된 경우 파일이 작성/업데이트되어 디바이스가 활성 상태임을 증명합니다. if ($DataPath) { $deviceFile = Get-ChildItem -Path $DataPath -Filter "${Hostname}*" -File -ErrorAction SilentlyContinue | Select-Object -First 1 if ($deviceFile) { $hoursSinceWrite = ((Get-Date) - $deviceFile.LastWriteTime). TotalHours if ($hoursSinceWrite -lt 72) { return $true } } } # 메서드 2: ping으로 대체(JSON이 부실하거나 누락된 경우에만) try { $ping = Test-Connection -ComputerName $Hostname -Count 1 -Quiet -ErrorAction SilentlyContinue return $ping } catch { return $false } }
function Update-BlockedBuckets { param( $RolloutState $BlockedBuckets $AdminApproved [array]$NotUpdatedDevices, [hashtable]$NotUpdatedIndexes, [int]$MaxWaitHours, [bool]$DryRun = $false ) $now = Get-Date $newlyBlocked = @() $stillWaiting = @() $devicesToCheck = @() $hostSet = if ($NotUpdatedIndexes -및 $NotUpdatedIndexes.HostSet) { $NotUpdatedIndexes.HostSet } else { (Get-NotUpdatedIndexes -Devices $NotUpdatedDevices). HostSet } $bucketCounts = if ($NotUpdatedIndexes -및 $NotUpdatedIndexes.BucketCounts) { $NotUpdatedIndexes.BucketCounts } else { (Get-NotUpdatedIndexes -Devices $NotUpdatedDevices). BucketCounts } # 대기 기간이 지났고 아직 업데이트되지 않은 디바이스 수집 foreach($RolloutState.WaveHistory의 $wave) { if (-not $wave. StartedAt) { continue } $waveStart = [DateTime]::P arse($wave. StartedAt) $hoursSinceWave = ($now - $waveStart). TotalHours if ($hoursSinceWave -lt $MaxWaitHours) { # 아직 대기 기간 내에 - 아직 검사 않음 계속 } # 이 웨이브에서 각 디바이스 확인 foreach($wave $deviceInfo. 디바이스) { $hostname = $deviceInfo.Hostname $bucketKey = $deviceInfo.BucketKey # 버킷이 이미 차단된 경우 건너뜁니다. if ($BlockedBuckets.Contains($bucketKey)) { continue } # 버킷이 관리자 승인이고 웨이브가 승인되기 전에 시작된 경우 건너뜁니다. #(재 차단에 대한 사후 관리자 승인 대상 디바이스만 검사) if ($AdminApproved -and $AdminApproved.Contains($bucketKey)) { $approvalTime = [DateTime]::P arse($AdminApproved[$bucketKey]. ApprovedAt) if ($waveStart -lt $approvalTime) { # 이 디바이스는 관리자 승인 전에 대상으로 지정되었습니다. - 건너뛰기 계속 } # 웨이브 승인 후 시작 - 이것은 새로운 대상입니다, 검사 } # 이 디바이스가 여전히 NotUpdated 목록에 있나요? if ($hostSet.Contains($hostname)) { $devicesToCheck += @{ 호스트 이름 = $hostname BucketKey = $bucketKey WaveNumber = $wave. WaveNumber HoursSinceWave = [math]::Round($hoursSinceWave, 1) } } } } if ($devicesToCheck.Count -eq 0) { return $newlyBlocked } Write-Log "대기 기간을 지난 $($devicesToCheck.Count) 디바이스의 연결 가능성 확인..." "INFO" # 의사 결정을 위한 버킷당 오류 추적 $bucketFailures = @{} # BucketKey -> @{ Unreachable=@(); Alive=@() } # 각 디바이스의 연결 가능성 확인 foreach($devicesToCheck $device) { $hostname = $device. 호스트 $bucketKey = $device. BucketKey if ($DryRun) { Write-Log "[DRYRUN] 연결 가능성을 검사 $hostname 것" "정보" 계속 } if (-not $bucketFailures.ContainsKey($bucketKey)) { $bucketFailures[$bucketKey] = @{ 연결할 수 없음 = @(); AliveButFailed = @(); WaveNumber = $device. WaveNumber; HoursSinceWave = $device. HoursSinceWave } } $isReachable = Test-DeviceReachable -Hostname $hostname -DataPath $AggregationInputPath if (-not $isReachable) { $bucketFailures[$bucketKey]. 연결할 수 없음 += $hostname } else { # 디바이스에 연결할 수 있지만 아직 업데이트되지 않음 - 일시적인 오류 또는 재부팅 대기 중일 수 있음 $bucketFailures[$bucketKey]. AliveButFailed += $hostname $stillWaiting += $hostname } } # 버킷당 결정: 디바이스에 실제로 연결할 수 없는 경우에만 차단 # 오류가 있는 활성 디바이스 = 임시, 계속 출시 foreach($bucketFailures.Keys의 $bucketKey) { $bf = $bucketFailures[$bucketKey] $unreachableCount = $bf. Unreachable.Count $aliveFailedCount = $bf. AliveButFailed.Count # 이 버킷이 성공했는지 확인합니다(업데이트된 디바이스 데이터에서). $bucketHasSuccesses = $stSuccessBuckets 및 $stSuccessBuckets.Contains($bucketKey) if ($unreachableCount -gt 0 -and $aliveFailedCount -eq 0) { # 모든 실패한 디바이스에 연결할 수 없음 - 버킷 차단 if ($newlyBlocked -notcontains $bucketKey) { $BlockedBuckets[$bucketKey] = @{ BlockedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Reason = "$($bf 이후에 모든 $unreachableCount 디바이스에 연결할 수 없습니다. 시간SinceWave) 시간" FailedDevices = ($bf. 연결할 수 없는 -join ", ") WaveNumber = $bf. WaveNumber DevicesInBucket = if ($bucketCounts.ContainsKey($bucketKey)) { $bucketCounts[$bucketKey] } else { 0 } } $newlyBlocked += $bucketKey Write-Log "버킷 차단됨: $bucketKey($unreachableCount 디바이스에 연결할 수 없음: $($bf. 연결할 수 없음 -join ', '))' "BLOCKED" } } elseif ($aliveFailedCount -gt 0) { # 디바이스가 활성 상태이지만 업데이트되지 않음 - 일시적인 오류, 차단 안 함 Write-Log "Bucket $($bucketKey.Substring(0, [Math]::Min(16, $bucketKey.Length)))...: $aliveFailedCount 디바이스가 활성 상태이지만 보류 중 $unreachableCount이며 연결할 수 없음 - 차단 안 함(임시)" "INFO" if ($unreachableCount -gt 0) { Write-Log " 연결할 수 없음: $($bf. 연결할 수 없음 -join ', ')" "WARN" } Write-Log " 살아 있지만 보류 중: $($bf. AliveButFailed -join ', ')" "INFO" # 모니터링을 위한 롤아웃 상태의 오류 수 추적 if (-not $RolloutState.TemporaryFailures) { $RolloutState.TemporaryFailures = @{} } $RolloutState.TemporaryFailures[$bucketKey] = @{ AliveButFailed = $bf. AliveButFailed 연결할 수 없음 = $bf. 연결할 LastChecked = Get-Date -Format "yyyy-MM-dd HH:mm:ss" } } } if ($stillWaiting.Count -gt 0) { Write-Log "디바이스에 연결할 수 있지만 보류 중인 업데이트(다시 부팅해야 할 수 있음): $($stillWaiting.Count)" "INFO" } return $newlyBlocked }
# ============================================================================ # AUTO-UNBLOCK: 디바이스가 성공적으로 업데이트될 때 버킷 차단 해제 # ============================================================================
function Update-AutoUnblockedBuckets { <# . 설명 차단된 버킷의 디바이스가 업데이트되었는지 확인합니다(이벤트 1808). 버킷의 모든 대상 디바이스가 업데이트된 경우 자동으로 차단을 해제합니다.일부 디바이스만 업데이트된 경우 수동으로 차단을 해제할 수 있는 관리자에게 알 수 있습니다. 관리 다음을 사용하여 수동으로 차단을 해제할 수 있습니다. .\Start-SecureBootRolloutOrchestrator.ps1 -ReportBasePath "path" -UnblockBucket "BucketKey" #> param( $BlockedBuckets $RolloutState [array]$NotUpdatedDevices, [string]$ReportBasePath [hashtable]$NotUpdatedIndexes [int]$LogSampleSize = 25 ) $autoUnblocked = @() $bucketsToCheck = @($BlockedBuckets.Keys) $hostSet = if ($NotUpdatedIndexes -및 $NotUpdatedIndexes.HostSet) { $NotUpdatedIndexes.HostSet } else { (Get-NotUpdatedIndexes -Devices $NotUpdatedDevices). HostSet } foreach($bucketsToCheck $bucketKey) { $bucketInfo = $BlockedBuckets[$bucketKey] # 지금까지 이 버킷에서 대상으로 지정한 모든 디바이스 가져오기 $targetedDevicesInBucket = @() foreach($RolloutState.WaveHistory의 $wave) { $targetedDevicesInBucket += @($wave. 디바이스 | Where-Object { $_. BucketKey -eq $bucketKey }) } if ($targetedDevicesInBucket.Count -eq 0) { continue } # NotUpdated 및 업데이트된 대상 디바이스의 수 확인 $updatedDevices = @() $stillPendingDevices = @() foreach($targetedDevicesInBucket $targetedDevice) { if ($hostSet.Contains($targetedDevice.Hostname)) { $stillPendingDevices += $targetedDevice.Hostname } else { $updatedDevices += $targetedDevice.Hostname } } if ($updatedDevices.Count -gt 0 -and $stillPendingDevices.Count -eq 0) { # 모든 대상 디바이스가 업데이트되었습니다. 자동 차단 해제! $BlockedBuckets.Remove($bucketKey) $autoUnblocked += @{ BucketKey = $bucketKey UpdatedDevices = $updatedDevices PreviouslyBlockedAt = $bucketInfo.BlockedAt 이유 = "모든 $($updatedDevices.Count) 대상 디바이스가 성공적으로 업데이트되었습니다." } Write-Log "AUTO-UNBLOCKED: $bucketKey(모든 $($updatedDevices.Count) 대상 디바이스가 성공적으로 업데이트됨)" "확인" # 이 버킷의 OEM에 대한 OEM 웨이브 수 증가(OEM별 추적) $bucketOEM = if($bucketKey -match '\|') { ($bucketKey -split '\|')[0] } else { 'Unknown' } # 파이프로 구분된 키 또는 기본값에서 OEM 추출 if (-not $RolloutState.OEMWaveCounts) { $RolloutState.OEMWaveCounts = @{} } $currentWave = if ($RolloutState.OEMWaveCounts[$bucketOEM]) { $RolloutState.OEMWaveCounts[$bucketOEM] } else { 0 } $RolloutState.OEMWaveCounts[$bucketOEM] = $currentWave + 1 Write-Log "OEM '$bucketOEM' 웨이브 수가 $($currentWave + 1)로 증가함(다음 할당: $([int][Math]::P ow(2, $currentWave + 1)) 디바이스)" "INFO" } elseif($updatedDevices.Count -gt 0 -and $stillPendingDevices.Count -gt 0) { # 일부 디바이스가 업데이트되었지만 다른 디바이스는 여전히 보류 중입니다. 관리자에게 알림(한 번만) if (-not $bucketInfo.UnblockCandidate) { $bucketInfo.UnblockCandidate = $true $bucketInfo.UpdatedDevices = $updatedDevices $bucketInfo.PendingDevices = $stillPendingDevices $bucketInfo.NotifiedAt = (Get-Date). ToString("yyyy-MM-dd HH:mm:ss") Write-Log "" "INFO" "차단된 버킷 ========== 부분 업데이트 ==========" "정보" Write-Log Write-Log "버킷: $bucketKey" "INFO" $updatedSample = @($updatedDevices | Select-Object -First $LogSampleSize) $pendingSample = @($stillPendingDevices | Select-Object -First $LogSampleSize) $updatedSuffix = if ($updatedDevices.Count -gt $LogSampleSize) { " ... (+$($updatedDevices.Count - $LogSampleSize)" } else { "" } $pendingSuffix = if ($stillPendingDevices.Count -gt $LogSampleSize) { " ... (+$($stillPendingDevices.Count - $LogSampleSize)" } else { "" } Write-Log "업데이트된 디바이스($($updatedDevices.Count)): $($updatedSample -join ', ')$updatedSuffix" "OK" Write-Log "여전히 보류 중($($stillPendingDevices.Count)): $($pendingSample -join ', ')$pendingSuffix" "WARN" Write-Log "" "INFO" Write-Log "확인 후 이 버킷을 수동으로 차단 해제하려면 다음을 실행합니다." "INFO" Write-Log " .\Start-SecureBootRolloutOrchestrator.ps1 -ReportBasePath '"$ReportBasePath'" -UnblockBucket '"$bucketKey"" "INFO" Write-Log "=======================================================" "INFO" Write-Log "" "INFO" } } } return $autoUnblocked }
# ============================================================================ # WAVE GENERATION(INLINED - 차단된 버킷 제외) # ============================================================================
function New-RolloutWave { param( [string]$AggregationPath $BlockedBuckets $RolloutState [int]$MaxDevicesPerWave = 50, [string[]]$AllowedHostnames = @(), [string[]]$ExcludedHostnames = @() ) # 집계 데이터 로드 $notUptodateCsv = Get-ChildItem -Path $AggregationPath -Filter "*NotUptodate*.csv" | Where-Object { $_. 이름 -notlike "*Buckets*" } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $notUptodateCsv) { Write-Log "NotUptodate CSV를 찾을 수 없음" "ERROR" return $null } $allNotUpdated = @(Import-Csv $notUptodateCsv.FullName) # 일관성을 위해 HostName -> Hostname 정규화(CSV는 HostName을 사용하고 코드는 Hostname을 사용). foreach($allNotUpdated $device) { if($device. PSObject.Properties['HostName'] -and -not $device. PSObject.Properties['Hostname']) { $device | Add-Member -NotePropertyName 'Hostname' -NotePropertyValue $device. HostName -Force } } # 차단된 버킷 필터링 $eligibleDevices = @($allNotUpdated | Where-Object { $bucketKey = Get-BucketKey $_ -not $BlockedBuckets.Contains($bucketKey) }) # 허용된 디바이스로만 필터링(AllowList가 지정된 경우) # AllowList = 대상 롤아웃 - 이러한 디바이스만 고려됩니다. if ($AllowedHostnames.Count -gt 0) { $beforeCount = $eligibleDevices.Count $eligibleDevices = @($eligibleDevices | Where-Object { $_. 호스트 이름 -in $AllowedHostnames }) $allowedCount = $eligibleDevices.Count Write-Log "AllowList 적용됨: $beforeCount 디바이스의 $allowedCount 허용 목록에 있습니다" "INFO" } # VIP/제외된 디바이스 필터링(BlockList) # BlockList가 AFTER AllowList에 적용됨 if ($ExcludedHostnames.Count -gt 0) { $beforeCount = $eligibleDevices.Count $eligibleDevices = @($eligibleDevices | Where-Object { $_. 호스트 이름 -notin $ExcludedHostnames }) $excludedCount = $beforeCount - $eligibleDevices.Count if ($excludedCount -gt 0) { Write-Log "롤아웃에서 $EXCLUDEDCOUNT VIP/보호된 디바이스 제외" "INFO" } } if ($eligibleDevices.Count -eq 0) { Write-Log "적격 디바이스가 남아 있지 않음(모든 업데이트 또는 차단됨)" "확인" return $null } # 이미 출시 중인 디바이스 가져오기(이전 웨이브에서) $devicesAlreadyInRollout = @() if ($RolloutState.WaveHistory -and $RolloutState.WaveHistory.Count -gt 0) { $devicesAlreadyInRollout = @($RolloutState.WaveHistory | ForEach-Object { $_. 디바이스 | ForEach-Object { $_. 호스트 이름 } } | Where-Object { $_ }) } Write-Log "이미 출시 중인 디바이스: $($devicesAlreadyInRollout.Count)" "INFO" # 신뢰도 수준으로 구분 $highConfidenceDevices = @($eligibleDevices | Where-Object { $_. ConfidenceLevel -eq "High Confidence" -and $_. 호스트 이름 -notin $devicesAlreadyInRollout }) # 작업 필수 항목은 다음과 같습니다. # - 명시적 "작업 필요" # - 빈/null ConfidenceLevel # - 알 수 없거나 인식할 수 없는 ConfidenceLevel 값(작업 필요로 처리됨) $knownSafeCategories = @( "높은 신뢰도", "일시적으로 일시 중지됨", "관찰 중", "관찰 중 - 더 많은 데이터가 필요", "지원되지 않음", "지원되지 않음 - 알려진 제한 사항" ) $actionRequiredDevices = @($eligibleDevices | Where-Object { $_. ConfidenceLevel -notin $knownSafeCategories -and $_. 호스트 이름 -notin $devicesAlreadyInRollout }) Write-Log "높은 신뢰도(출시되지 않음): $($highConfidenceDevices.Count)" "INFO" Write-Log "작업 필요(롤아웃 안 함): $($actionRequiredDevices.Count)" "INFO" # 웨이브 디바이스 빌드 $waveDevices = @() # 높은 신뢰도: ALL 포함(롤아웃에 안전) if ($highConfidenceDevices.Count -gt 0) { Write-Log "모든 $($highConfidenceDevices.Count) 높은 신뢰도 디바이스 추가" "WAVE" $waveDevices += $highConfidenceDevices } # ACTION 필수: 점진적 롤아웃(성공이 없는 버킷에 대한 OEM 스프레드를 사용하는 버킷 기반) # 전략: # - 성공이 0인 버킷: OEM에 분산(OEM당 1개 -OEM당 2개 -> OEM당 2개 -OEM당 > 4) # - ≥1 성공이 있는 버킷: OEM 제한 없이 자유롭게 두 번 if ($actionRequiredDevices.Count -gt 0) { # 업데이트된 디바이스 CSV(성공적으로 업데이트된 디바이스)에서 버킷 로드 성공 횟수 $updatedCsv = Get-ChildItem -경로 $AggregationPath -필터 "*updated_devices*.csv" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $bucketStats = @{} if ($updatedCsv) { $updatedDevices = Import-Csv $updatedCsv.FullName # BucketId당 성공 수 $updatedDevices | ForEach-Object { $key = Get-BucketKey $_ if ($key) { if (-not $bucketStats.ContainsKey($key)) { $bucketStats[$key] = @{ Successes = 0; 보류 중 = 0; Total = 0 } } $bucketStats[$key]. Successes++ $bucketStats[$key]. Total++ } } Write-Log "$($bucketStats.Count) 버킷에서 $($updatedDevices.Count) 업데이트된 디바이스" "INFO" } else { # 대체: ActionRequired_Buckets CSV 사용해 보기 $bucketsCsv = Get-ChildItem -Path $AggregationPath -Filter "*ActionRequired_Buckets*.csv" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($bucketsCsv) { Import-Csv $bucketsCsv.FullName | ForEach-Object { $key = if ($_. BucketId) { $_. BucketId } else { "$($_. 제조업체)|$($_. 모델)|$($_. BIOS)" } $bucketStats[$key] = @{ Successes = [int]$_. 성공 보류 중 = [int]$_. 보류 중인 Total = [int]$_. TotalDevices } } } } # 버킷별 NotUpdated 디바이스 그룹화(제조업체|모델|BIOS) $buckets = $actionRequiredDevices | Group-Object { Get-BucketKey $_ } # 개별 버킷: 성공 0 대 성공 $zeroSuccessBuckets = @() $hasSuccessBuckets = @() foreach($buckets $bucket) { $bucketKey = $bucket. 이름 $bucketDevices = @($bucket. 그룹) $bucketHostnames = @($bucketDevices | ForEach-Object { $_. 호스트 이름 }) # 이 버킷의 성공 횟수 $stats = $bucketStats[$bucketKey] $successes = if ($stats) { $stats. Successes } else { 0 } # 웨이브 기록에서 이 버킷에 배포된 디바이스 찾기 $deployedToBucket = @() foreach($RolloutState.WaveHistory의 $wave) { foreach($wave $device. 디바이스) { if($device. BucketKey -eq $bucketKey 및 $device. 호스트 이름) { $deployedToBucket += $device. 호스트 } } } $deployedToBucket = @($deployedToBucket | Sort-Object -Unique) # 모든 배포된 디바이스가 성공을 보고했는지 확인 $stillPending = @($deployedToBucket | Where-Object { $_ -in $bucketHostnames }) $confirmedSuccess = $deployedToBucket.Count - $stillPending.Count # 보류 중인 경우 모든 확인이 있을 때까지 이 버킷을 건너뜁니다. if ($stillPending.Count -gt 0) { $parts = $bucketKey -split '\|' $displayName = "$($parts[0]) - $($parts[1])" Write-Log " 버킷: $displayName - Deployed=$($deployedToBucket.Count), Confirmed=$confirmedSuccess, Pending=$($stillPending.Count)(대기 중)" "INFO" 계속 } # 남은 적격 = 아직 배포되지 않은 디바이스 $devicesNotYetTargeted = @($bucketDevices | Where-Object { $_. 호스트 이름 -notin $deployedToBucket }) if ($devicesNotYetTargeted.Count -eq 0) { continue } # 성공 횟수별로 분류 $bucketInfo = @{ BucketKey = $bucketKey 디바이스 = $devicesNotYetTargeted ConfirmedSuccess = $confirmedSuccess 성공 = $successes OEM = if($bucket. 그룹[0]. WMI_Manufacturer) { $bucket. 그룹[0]. WMI_Manufacturer } elseif($bucketKey -match '\|') { ($bucketKey -split '\|')[0] } else { 'Unknown' } } if ($successes -eq 0) { $zeroSuccessBuckets += $bucketInfo } else { $hasSuccessBuckets += $bucketInfo } } # === PROCESS HAS-SUCCESS BUCKETS(≥1 success) === # 성공 횟수의 두 배 - 14개 성공하면 다음으로 28개 배포 foreach($hasSuccessBuckets $bucketInfo) { $nextBatchSize = $bucketInfo.Successes * 2 $nextBatchSize = [Math]::Min($nextBatchSize, $MaxDevicesPerWave) $nextBatchSize = [Math]::Min($nextBatchSize, $bucketInfo.Devices.Count) if ($nextBatchSize -gt 0) { $selectedDevices = @($bucketInfo.Devices | Select-Object -First $nextBatchSize) $waveDevices += $selectedDevices $parts = if($bucketInfo.BucketKey -match '\|') { $bucketInfo.BucketKey -split '\|' } else { @($bucketInfo.OEM, $bucketInfo.BucketKey.Substring(0, [Math]::Min(12, $bucketInfo.BucketKey.Length)) } $displayName = "$($parts[0]) - $($parts[1])" Write-Log " [HAS-SUCCESS] $displayName - Successes=$($bucketInfo.Successes), Deploying=$nextBatchSize(2x confirmed)" "INFO" } } # === PROCESS ZERO-SUCCESS BUCKETS(OEM별 추적을 사용하여 OEM에 분산) === # 목표: 여러 OEM에 위험 분산, OEM별 진행률 독립적으로 추적 # 각 OEM은 자체 성공 기록에 따라 진행됩니다. # - OEM 성공: 다음 웨이브에 더 많은 디바이스 가져오기(2^waveCount) # - 성공하지 못한 OEM: 성공이 확인될 때까지 현재 수준으로 유지됨 if ($zeroSuccessBuckets.Count -gt 0) { # 없는 경우 OEM별 웨이브 수 초기화 if (-not $RolloutState.OEMWaveCounts) { $RolloutState.OEMWaveCounts = @{} } # OEM별 성공 버킷 그룹화 $oemBuckets = $zeroSuccessBuckets | Group-Object { $_. OEM } $totalZeroSuccessAdded = 0 $oemsDeployedTo = @() foreach($oemBuckets $oemGroup) { $oemName = $oemGroup.Name # 이 OEM의 웨이브 수 가져오기(0에서 시작) $oemWaveCount = if ($RolloutState.OEMWaveCounts[$oemName]) { $RolloutState.OEMWaveCounts[$oemName] } else { 0 } # 이 OEM에 대한 디바이스 계산: 2^waveCount(1, 2, 4, 8...) $devicesForThisOEM = [int][Math]::P ow(2, $oemWaveCount) $devicesForThisOEM = [Math]::Max(1, $devicesForThisOEM) $oemDevicesAdded = 0 # 이 OEM 아래의 각 버킷에서 선택 foreach($oemGroup.Group의 $bucketInfo) { $remaining = $devicesForThisOEM - $oemDevicesAdded if ($remaining -le 0) { break } $toTake = [Math]::Min($remaining, $bucketInfo.Devices.Count) if ($toTake -gt 0) { $selectedDevices = @($bucketInfo.Devices | Select-Object -First $toTake) $waveDevices += $selectedDevices $oemDevicesAdded += $toTake $totalZeroSuccessAdded += $toTake $parts = if($bucketInfo.BucketKey -match '\|') { $bucketInfo.BucketKey -split '\|' } else { @($bucketInfo.OEM, $bucketInfo.BucketKey.Substring(0, [Math]::Min(12, $bucketInfo.BucketKey.Length)) } $displayName = "$($parts[0]) - $($parts[1])" Write-Log " [ZERO-SUCCESS] $displayName - Deploying=$toTake(OEM 웨이브 $oemWaveCount = ${devicesForThisOEM}/OEM)" "WARN" } } if ($oemDevicesAdded -gt 0) { Write-Log " OEM: $oemName - 웨이브 $oemWaveCount, $oemDevicesAdded 디바이스 추가" "INFO" $oemsDeployedTo += $oemName } } # 배포한 OEM 추적(다음 성공 검사 증가) if ($oemsDeployedTo.Count -gt 0) { $RolloutState.PendingOEMWaveIncrement = $oemsDeployedTo Write-Log "성공하지 않음 배포: $($oemsDeployedTo.Count) OEM에서 디바이스 $totalZeroSuccessAdded" "INFO" } } } if (@($waveDevices). Count -eq 0) { return $null } return $waveDevices }
# ============================================================================ # GPO 배포(INLINED - GPO, 보안 그룹, 링크 만들기) # ============================================================================
function Deploy-GPOForWave { param( [string]$GPOName, [string]$TargetOU [string]$SecurityGroupName, [array]$WaveHostnames, [bool]$DryRun = $false ) # ADMX 정책: SecureBoot.admx - SecureBoot_AvailableUpdatesPolicy # 레지스트리 경로: HKLM\SYSTEM\CurrentControlSet\Control\SecureBoot # 값 이름: AvailableUpdatesPolicy # 사용 값: 22852(0x5944) - 모든 보안 부팅 키 업데이트 + bootmgr # 사용 안 함 값: 0 # # 신뢰할 수 있는 HKLM\SYSTEM 경로 배포에 GPP(그룹 정책 기본 설정) 사용 # GPP에서 설정을 만듭니다. 컴퓨터 구성 > 기본 설정 > Windows 설정 > 레지스트리 $RegistryKey = "HKLM\SYSTEM\CurrentControlSet\Control\SecureBoot" $RegistryValueName = "AvailableUpdatesPolicy" $RegistryValue = 22852 # 0x5944 - ADMX enabledValue와 일치합니다. Write-Log "GPO 배포: $GPOName" "WAVE" Write-Log "레지스트리: $RegistryKey\$RegistryValueName = $RegistryValue (0x$($RegistryValue.ToString('X')))" "INFO" if ($DryRun) { Write-Log "[DRYRUN] GPO를 만들 것: $GPOName" "INFO" Write-Log "[DRYRUN] 보안 그룹을 만들 것: $SecurityGroupName" "INFO" Write-Log "[DRYRUN] $(@($WaveHostnames)을 추가합니다. Count) 그룹화할 컴퓨터" "INFO" Write-Log "[DRYRUN] GPO를 $TargetOU" "INFO"에 연결합니다. return $true } try { # 필수 모듈 가져오기 Import-Module GroupPolicy -ErrorAction 중지 Import-Module ActiveDirectory -ErrorAction 중지 } catch { Write-Log "필요한 모듈을 가져오지 못했습니다(GroupPolicy, ActiveDirectory): $($_. Exception.Message)" "ERROR" return $false } # 1단계: GPO 만들기 또는 가져오기 $existingGPO = Get-GPO -Name $GPOName -ErrorAction SilentlyContinue if ($existingGPO) { Write-Log "GPO가 이미 있음: $GPOName" "INFO" $gpo = $existingGPO } else { try { $gpo = New-GPO -Name $GPOName -주석 "보안 부팅 인증서 롤아웃 - AvailableUpdatesPolicy=0x5944 - 만든 $(Get-Date -Format 'yyyy-MM-dd HH:mm')" Write-Log "만든 GPO: $GPOName" "확인" } catch { Write-Log "GPO를 만들지 못했습니다. $($_) Exception.Message)" "ERROR" return $false } } # 2단계: GPP(그룹 정책 기본 설정)를 사용하여 레지스트리 값 설정 # GPP는 Set-GPRegistryValue보다 HKLM\SYSTEM 경로에 더 안정적입니다. try { # 먼저 이 값에 대한 기존 기본 설정을 제거합니다(중복을 방지하려면). Remove-GPPrefRegistryValue -Name $GPOName -Context Computer -Key $RegistryKey -ValueName $RegistryValueName -ErrorAction SilentlyContinue # "바꾸기" 작업을 사용하여 GPP 레지스트리 기본 설정 만들기 # 바꾸기 = 존재하지 않는 경우 만들기, 존재하는 경우 업데이트(가장 신뢰할 수 있음) # 업데이트 = 존재하는 경우에만 업데이트(값이 없는 경우 실패) Set-GPPrefRegistryValue -name $GPOName ' -Context Computer ' -Action Replace ' -Key $RegistryKey ' -ValueName $RegistryValueName ' -형식 DWord ' -Value $RegistryValue Write-Log "구성된 GPP 레지스트리 기본 설정: $RegistryValueName = 0x5944(Action=Replace)" "OK" } catch { Write-Log "GPP가 실패했습니다. Set-GPRegistryValue: $($_. Exception.Message)" "WARN" # Set-GPRegistryValue 대체(ADMX가 배포된 경우 작동) try { Set-GPRegistryValue -Name $GPOName ' -Key $RegistryKey ' -ValueName $RegistryValueName ' -형식 DWord ' -Value $RegistryValue Write-Log "Set-GPRegistryValue를 통해 구성된 레지스트리: $RegistryValueName = 0x5944" "확인" } catch { Write-Log "레지스트리 값을 설정하지 못했습니다. $($_) Exception.Message)" "ERROR" return $false } } # 3단계: 보안 그룹 만들기 또는 가져오기 $existingGroup = Get-ADGroup -Filter "Name -eq '$SecurityGroupName'" -ErrorAction SilentlyContinue if (-not $existingGroup) { try { $group = New-ADGroup -Name $SecurityGroupName ' -GroupCategory Security ' -GroupScope DomainLocal ' -Description "보안 부팅 롤아웃을 대상으로 하는 컴퓨터 - $GPOName" ' -Passthru Write-Log "만든 보안 그룹: $SecurityGroupName" "확인" } catch { Write-Log "보안 그룹을 만들지 못했습니다. $($_) Exception.Message)" "ERROR" return $false } } else { Write-Log "보안 그룹 존재: $SecurityGroupName" "INFO" $group = $existingGroup } # 4단계: 보안 그룹에 컴퓨터 추가 $added = 0 $failed = 0 foreach($WaveHostnames $hostname) { try { $computer = Get-ADComputer -ID $hostname -ErrorAction 중지 Add-ADGroupMember -ID $SecurityGroupName -멤버 $computer -ErrorAction SilentlyContinue $added++ } catch { $failed++ } } Write-Log "보안 그룹에 $added 컴퓨터 추가(AD에서 찾을 $failed 없음)" "확인" # 5단계: GPO에서 보안 필터링 구성 try { # 기본 "인증된 사용자" 권한 적용 제거(읽기 유지) Set-GPPermission -Name $GPOName -TargetName "인증된 사용자" -TargetType 그룹 -PermissionLevel GpoRead -Replace -ErrorAction SilentlyContinue # 보안 그룹에 대한 적용 권한 추가 Set-GPPermission -Name $GPOName -TargetName $SecurityGroupName -TargetType Group -PermissionLevel GpoApply -ErrorAction Stop Write-Log "구성한 보안 필터링: $SecurityGroupName" "확인" } catch { Write-Log "보안 필터링을 구성하지 못했습니다. $($_. Exception.Message)" "WARN" Write-Log "연결된 OU의 모든 컴퓨터에 GPO가 적용될 수 있음 - 수동으로 확인" "WARN" } # 6단계: GPO를 OU에 연결(정책을 적용하는 데 중요) if ($TargetOU) { try { $existingLink = Get-GPInheritance -Target $TargetOU -ErrorAction SilentlyContinue | Select-Object -ExpandProperty GpoLinks | Where-Object { $_. DisplayName -eq $GPOName } if (-not $existingLink) { New-GPLink -이름 $GPOName -대상 $TargetOU -LinkEnabled 예 -ErrorAction 중지 "연결된 GPO: $TargetOU" "확인" Write-Log Write-Log "GPO는 대상 컴퓨터에서 다음 gpupdate에 적용됩니다" "INFO" } else { Write-Log "GPO가 대상 OU에 이미 연결됨" "INFO" } } catch { Write-Log "CRITICAL: GPO를 OU에 연결하지 못했습니다. $($_. Exception.Message)" "ERROR" Write-Log "GPO가 만들어졌지만 연결되지 않음 - 컴퓨터에는 적용되지 않습니다!" "ERROR" Write-Log "수동 수정 필요: New-GPLink -이름 '$GPOName' -대상 '$TargetOU' -LinkEnabled 예" "ERROR" return $false } } else { Write-Log "경고: TargetOU가 지정되지 않음 - GPO가 생성되었지만 연결되지 않음!" "ERROR" Write-Log "GPO가 적용되는 데 필요한 수동 연결" "ERROR" Write-Log "Run: New-GPLink -Name '$GPOName' -Target '<Your-Domain-DN>' -LinkEnabled Yes" "ERROR" } # 7단계: GPO 구성 확인 Write-Log "GPO 구성 확인..." "INFO" try { $gpoReport = Get-GPO -Name $GPOName -ErrorAction Stop Write-Log "GPO 상태: $($gpoReport.GpoStatus)" "INFO" # 레지스트리 설정이 구성되었는지 확인 $regSettings = Get-GPRegistryValue -Name $GPOName -Key "HKLM\SYSTEM\CurrentControlSet\Control\SecureBoot" -ErrorAction SilentlyContinue if (-not $regSettings) { # GPP 레지스트리 검사 사용해 보기(GPO의 다른 경로) Write-Log "GPP 레지스트리 기본 설정 확인..." "INFO" } } catch { Write-Log "GPO를 확인할 수 없습니다. $($_. Exception.Message)" "WARN" } return $true }
# ============================================================================ # WINCS 배포(AvailableUpdatesPolicy GPO 대체) # ============================================================================ # 참조: https://support.microsoft.com/en-us/topic/windows-configuration-system-wincs-apis-for-secure-boot-d3e64aa0-6095-4f8a-b8e4-fbfda254a8fe # # WinCS 명령(SYSTEM 컨텍스트의 엔드포인트에서 실행): # 쿼리: WinCsFlags.exe /query --key F33E0C8E002 # 적용: WinCsFlags.exe /apply --key "F33E0C8E002" # 초기화: WinCsFlags.exe /reset --key "F33E0C8E002" # # 이 메서드는 WinCsFlags.exe /apply를 실행하는 예약된 작업을 사용하여 GPO를 배포합니다. 대상 엔드포인트의 SYSTEM으로 # 검색 스크립트를 배포하는 방법과 유사하게 #하지만 매일이 아닌 한 번(시작 시) 실행됩니다.
function Deploy-WinCSGPOForWave { <# . 시놉시스 GPO 예약 작업을 통해 WinCS 보안 부팅 사용을 배포합니다.. 설명 예약된 작업을 배포하여 WinCsFlags.exe /apply를 실행하는 GPO를 만듭니다. 컴퓨터 시작 시 SYSTEM 컨텍스트 아래에 있습니다. 보안 그룹 컨트롤 대상 지정. PARAMETER GPOName GPO의 이름입니다.. PARAMETER TargetOU GPO를 연결할 OU입니다.. PARAMETER SecurityGroupName GPO 필터링을 위한 보안 그룹입니다.. PARAMETER WaveHostnames 보안 그룹에 추가할 호스트 이름입니다.. PARAMETER WinCSKey 적용할 WinCS 키(기본값: F33E0C8E002).. PARAMETER DryRun true이면 수행할 작업만 기록합니다.#> param( [Parameter(Mandatory = $true)] [string]$GPOName [Parameter(Mandatory = $false)] [string]$TargetOU, [Parameter(Mandatory = $true)] [string]$SecurityGroupName [Parameter(Mandatory = $true)] [array]$WaveHostnames, [Parameter(Mandatory = $false)] [string]$WinCSKey = "F33E0C8E002", [Parameter(Mandatory = $false)] [bool]$DryRun = $false ) # WinCsFlags.exe 예약된 작업 구성 $TaskName = "SecureBoot-WinCS-Apply" $TaskPath = "\Microsoft\Windows\SecureBoot\" $TaskDescription = "WinCS를 통해 보안 부팅 구성 적용 - 키: $WinCSKey" Write-Log "WinCS GPO 배포: $GPOName" "WAVE" Write-Log "작업이 실행됩니다. WinCsFlags.exe /apply --key '"$WinCSKey"" "INFO" Write-Log "트리거: 시스템 시작 시(SYSTEM으로 한 번 실행)" "INFO" if ($DryRun) { Write-Log "[DRYRUN] GPO를 만들 것: $GPOName" "정보" Write-Log "[DRYRUN] 보안 그룹을 만들 것: $SecurityGroupName" "INFO" Write-Log "[DRYRUN] $(@($WaveHostnames)을 추가합니다. Count) 그룹화할 컴퓨터" "INFO" Write-Log "[DRYRUN] 예약된 작업을 배포합니다. $TaskName" "INFO" Write-Log "[DRYRUN] GPO를 에 연결합니다. $TargetOU" "INFO" return @{ 성공 = $true GPOCreated = $false GroupCreated = $false ComputersAdded = 0 } } try { # 필수 모듈 가져오기 Import-Module GroupPolicy -ErrorAction 중지 Import-Module ActiveDirectory -ErrorAction 중지 } catch { Write-Log "필요한 모듈을 가져오지 못했습니다(GroupPolicy, ActiveDirectory): $($_. Exception.Message)" "ERROR" return @{ Success = $false; 오류 = $_. Exception.Message } } # 1단계: GPO 만들기 또는 가져오기 $gpo = Get-GPO -Name $GPOName -ErrorAction SilentlyContinue if ($gpo) { Write-Log "GPO가 이미 있음: $GPOName" "INFO" } else { try { $gpo = New-GPO -Name $GPOName -Comment "Secure Boot WinCS Deployment - $WinCSKey - Created $(Get-Date -Format 'yyyy-MM-dd HH:mm')" Write-Log "만든 GPO: $GPOName" "확인" } catch { Write-Log "GPO를 만들지 못했습니다. $($_) Exception.Message)" "ERROR" return @{ Success = $false; 오류 = $_. Exception.Message } } } # 2단계: GPO 배포를 위한 예약된 작업 XML 만들기 # 시작 시 /적용 WinCsFlags.exe 실행되는 작업을 만듭니다. $taskXml = @" <?xml version="1.0" encoding="UTF-16"?> <작업 버전="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> <RegistrationInfo> <설명>$TaskDescription</Description> WinCsFlags.exe1 Author>SYSTEM</Author> WinCsFlags.exe5 /RegistrationInfo> 트리거>WinCsFlags.exe7 WinCsFlags.exe9 BootTrigger> <사용>true</Enabled> <지연>PT5M</Delay> </BootTrigger> </Triggers> <보안 주체> <보안 주체 id="Author"> <UserId>S-1-5-18</UserId> <RunLevel>HighestAvailable</RunLevel> </Principal> </Principals> <설정> <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy> <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries> <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries> <AllowHardTerminate>true</AllowHardTerminate> <StartWhenAvailable>true</StartWhenAvailable> <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable> <IdleSettings> <StopOnIdleEnd>false</StopOnIdleEnd> <RestartOnIdle>false</RestartOnIdle> </IdleSettings> <AllowStartOnDemand>true</AllowStartOnDemand> <사용>true</Enabled> <숨김>false</Hidden> <RunOnlyIfIdle>false</RunOnlyIfIdle> WinCsFlags.exe03 DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession> WinCsFlags.exe07 UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine> WinCsFlags.exe11 WakeToRun>false</WakeToRun> WinCsFlags.exe15 ExecutionTimeLimit>PT1H</ExecutionTimeLimit> WinCsFlags.exe19 DeleteExpiredTaskAfter>P30D</DeleteExpiredTaskAfter> WinCsFlags.exe23 우선 순위>7</우선 순위> WinCsFlags.exe27 /설정> WinCsFlags.exe29 Actions Context="Author"WinCsFlags.exe30 WinCsFlags.exe31 Exec> WinCsFlags.exe33 명령>WinCsFlags.exe</Command> WinCsFlags.exe37 인수>/apply --key "$WinCSKey"WinCsFlags.exe39 /arguments> WinCsFlags.exe41 /Exec> WinCsFlags.exe43 /Actions> WinCsFlags.exe45 /Task> " @
# Step 3: Deploy scheduled task via GPO Preferences # GPO 예약된 태스크 직접 작업용 SYSVOL에 작업 XML 저장 try { $gpoId = $gpo. Id.ToString() $sysvolPath = "\\$((Get-ADDomain). DNSRoot)\SYSVOL\$((Get-ADDomain). DNSRoot)\Policies\{$gpoId}\Machine\Preferences\ScheduledTasks" if (-not (Test-Path $sysvolPath)) { New-Item -ItemType 디렉터리 -경로 $sysvolPath -Force | Out-Null } # GPP용 ScheduledTasks.xml 만들기 $gppTaskXml = @" <?xml version="1.0" encoding="utf-8"?> <ScheduledTasks clsid="{CC63F200-7309-4ba0-B154-A71CD118DBCC}"> <ImmediateTaskV2 clsid="{9756B581-76EC-4169-9AFC-0CA8D43ADB5F}" name="$TaskName" image="0" changed="$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" uid="{$([guid]::NewGuid(). ToString(). ToUpper())}"> <속성 action="C" name="$TaskName" runAs="NT AUTHORITY\System" logonType="S4U"> <작업 버전="1.3"> <RegistrationInfo> <설명>$TaskDescription</Description> </RegistrationInfo> <보안 주체> <보안 주체 id="Author"> <UserId>NT AUTHORITY\System</UserId> <LogonType>S4U</LogonType> <RunLevel>HighestAvailable</RunLevel> </Principal> </Principals> <설정> <IdleSettings> <기간>PT5M</duration> <WaitTimeout>PT1H</WaitTimeout> <StopOnIdleEnd>false</StopOnIdleEnd> <RestartOnIdle>false</RestartOnIdle> </IdleSettings> <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy> <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries> <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries> <AllowHardTerminate>true</AllowHardTerminate> <StartWhenAvailable>true</StartWhenAvailable> <AllowStartOnDemand>true</AllowStartOnDemand> <사용>true</Enabled> <숨김>false</Hidden> <ExecutionTimeLimit>PT1H</ExecutionTimeLimit> <우선 순위>7</Priority> <DeleteExpiredTaskAfter>PT0S</DeleteExpiredTaskAfter> </설정> 트리거>< <TimeTrigger> <StartBoundary>$(Get-Date -Format 'yyyy-MM-dd')T00:00:00</StartBoundary> <사용>true</Enabled> </TimeTrigger> </Triggers> <작업> <Exec> <명령>WinCsFlags.exe</Command> <인수>/apply --key "$WinCSKey"</arguments> </Exec> </Actions> </Task> </Properties> </ImmediateTaskV2> </ScheduledTasks> "@ $gppTaskXml | Out-File -FilePath(Join-Path $sysvolPath "ScheduledTasks.xml") -Encoding UTF8 -Force "GPO에 예약된 작업 배포: $TaskName" "확인" Write-Log } catch { Write-Log "예약된 작업 XML을 배포하지 못했습니다. $($_) Exception.Message)" "WARN" Write-Log "레지스트리 기반 WinCS 배포로 대체" "INFO" # 대체: GPP 예약된 작업이 실패하는 경우 WinCS 레지스트리 접근 방식 사용 # WinCS는 레지스트리 키를 통해 트리거할 수도 있습니다. #(구현은 사용 가능한 경우 WinCS 레지스트리 API에 따라 다름) } # 4단계: 보안 그룹 만들기 또는 가져오기 $group = Get-ADGroup -Filter "Name -eq '$SecurityGroupName'" -ErrorAction SilentlyContinue if (-not $group) { try { $group = New-ADGroup -Name $SecurityGroupName ' -GroupCategory Security ' -GroupScope DomainLocal ' -Description "보안 부팅 WinCS 롤아웃을 대상으로 하는 컴퓨터 - $GPOName" ' -Passthru Write-Log "만든 보안 그룹: $SecurityGroupName" "확인" } catch { Write-Log "보안 그룹을 만들지 못했습니다. $($_) Exception.Message)" "ERROR" return @{ Success = $false; 오류 = $_. Exception.Message } } } else { Write-Log "보안 그룹 존재: $SecurityGroupName" "INFO" } # 5단계: 보안 그룹에 컴퓨터 추가 $added = 0 $failed = 0 foreach($WaveHostnames $hostname) { try { $computer = Get-ADComputer -ID $hostname -ErrorAction 중지 Add-ADGroupMember -ID $SecurityGroupName -멤버 $computer -ErrorAction SilentlyContinue $added++ } catch { $failed++ } } Write-Log "보안 그룹에 $added 컴퓨터 추가(AD에서 찾을 $failed 없음)" "확인" # 6단계: GPO에서 보안 필터링 구성 try { Set-GPPermission -Name $GPOName -TargetName "인증된 사용자" -TargetType 그룹 -PermissionLevel GpoRead -Replace -ErrorAction SilentlyContinue Set-GPPermission -Name $GPOName -TargetName $SecurityGroupName -TargetType Group -PermissionLevel GpoApply -ErrorAction Stop Write-Log "구성한 보안 필터링: $SecurityGroupName" "확인" } catch { Write-Log "보안 필터링을 구성하지 못했습니다. $($_. Exception.Message)" "WARN" } # 7단계: GPO를 OU에 연결 if ($TargetOU) { try { $existingLink = Get-GPInheritance -Target $TargetOU -ErrorAction SilentlyContinue | Select-Object -ExpandProperty GpoLinks | Where-Object { $_. DisplayName -eq $GPOName } if (-not $existingLink) { New-GPLink -이름 $GPOName -대상 $TargetOU -LinkEnabled 예 -ErrorAction 중지 Write-Log "연결된 GPO: $TargetOU" "확인" } else { Write-Log "GPO가 대상 OU에 이미 연결됨" "INFO" } } catch { Write-Log "CRITICAL: GPO를 OU에 연결하지 못했습니다. $($_. Exception.Message)" "ERROR" return @{ Success = $false; 오류 = "GPO 링크 실패: $($_. Exception.Message)" } } } Write-Log "WinCS GPO 배포 완료" "확인" Write-Log "컴퓨터는 다음 GPO 새로 고침 + 다시 부팅/시작 시 WinCsFlags.exe 실행됩니다" "INFO" return @{ 성공 = $true GPOCreated = $true GroupCreated = $true ComputersAdded = $added ComputersFailed = $failed } }
# Wrapper function to maintain compatibility with main loop 함수 Deploy-WinCSForWave { param( [Parameter(Mandatory = $true)] [array]$WaveHostnames, [Parameter(Mandatory = $false)] [string]$WinCSKey = "F33E0C8E002", [Parameter(Mandatory = $false)] [string]$WavePrefix = "SecureBoot-Rollout", [Parameter(Mandatory = $false)] [int]$WaveNumber = 1, [Parameter(Mandatory = $false)] [string]$TargetOU [Parameter(Mandatory = $false)] [bool]$DryRun = $false ) $gpoName = "${WavePrefix}-WinCS-Wave${WaveNumber}" $securityGroup = "${WavePrefix}-WinCS-Wave${WaveNumber}" $result = Deploy-WinCSGPOForWave ' -GPOName $gpoName ' -TargetOU $TargetOU ' -SecurityGroupName $securityGroup ' -WaveHostnames $WaveHostnames ' -WinCSKey $WinCSKey ' -DryRun $DryRun # 예상 반환 형식으로 변환 return @{ 성공 = $result. 성공 적용됨 = $result. 컴퓨터추가됨 건너뛰기 = 0 실패 = if($result. ComputersFailed) { $result. ComputersFailed } else { 0 } 결과 = @() } }
# ============================================================================ # 작업 배포 사용 # ============================================================================ # 예약된 작업이 비활성화된 디바이스에 Enable-SecureBootUpdateTask.ps1 배포합니다.# 한 번 실행되는 즉시 예약된 작업과 함께 GPO를 사용합니다.
function Deploy-EnableTaskGPO { <# . 시놉시스 GPO 예약 작업을 통해 Enable-SecureBootUpdateTask.ps1 배포합니다.. 설명 일회용 예약 작업을 배포하여 대상 디바이스에서 Secure-Boot-Update 예약된 작업.. PARAMETER TargetOU GPO를 연결할 OU입니다.. PARAMETER TargetHostnames 비활성화된 작업이 있는 디바이스의 호스트 이름(집계 보고서)입니다.. PARAMETER DryRun true이면 수행할 작업만 기록합니다.#> param( [Parameter(Mandatory = $false)] [string]$TargetOU [Parameter(Mandatory = $true)] [array]$TargetHostnames, [Parameter(Mandatory = $false)] [bool]$DryRun = $false ) $GPOName = "SecureBoot-EnableTask-Remediation" $SecurityGroupName = "SecureBoot-EnableTask-Devices" $TaskName = "SecureBoot-EnableTask-OneTime" $TaskDescription = "Secure-Boot-Update 예약된 작업을 사용하도록 설정하는 일회성 작업" Write-Log "=" * 70 "INFO" Write-Log "작업 수정 사용 배포" "정보" Write-Log "=" * 70 "INFO" Write-Log "대상 디바이스: $($TargetHostnames.Count)" "INFO" Write-Log "GPO: $GPOName" "INFO" Write-Log "보안 그룹: $SecurityGroupName" "INFO" if ($DryRun) { Write-Log "[DRYRUN] GPO를 만들 것: $GPOName" "정보" Write-Log "[DRYRUN] 보안 그룹을 만들 것: $SecurityGroupName" "INFO" Write-Log "[DRYRUN] 그룹화에 $($TargetHostnames.Count) 컴퓨터를 추가합니다." "INFO" Write-Log "[DRYRUN] Secure-Boot-Update" "INFO"를 사용하도록 일회성 예약된 작업을 배포합니다. Write-Log "[DRYRUN] GPO를 $TargetOU" "INFO"에 연결합니다. return @{ 성공 = $true ComputersAdded = 0 DryRun = $true } } try { # 필수 모듈 가져오기 Import-Module GroupPolicy -ErrorAction 중지 Import-Module ActiveDirectory -ErrorAction 중지 } catch { Write-Log "필요한 모듈을 가져오지 못했습니다. $($_) Exception.Message)" "ERROR" return @{ Success = $false; 오류 = $_. Exception.Message } } # 1단계: GPO 만들기 또는 가져오기 $gpo = Get-GPO -Name $GPOName -ErrorAction SilentlyContinue if ($gpo) { Write-Log "GPO가 이미 있음: $GPOName" "INFO" } else { try { $gpo = New-GPO -Name $GPOName -주석 "보안 부팅 작업 수정 사용 - 만든 $(Get-Date -Format 'yyyy-MM-dd HH:mm')" Write-Log "만든 GPO: $GPOName" "확인" } catch { Write-Log "GPO를 만들지 못했습니다. $($_) Exception.Message)" "ERROR" return @{ Success = $false; 오류 = $_. Exception.Message } } } # 2단계: GPO SYSVOL에 예약된 작업 XML 배포 # 태스크는 PowerShell 명령을 실행하여 Secure-Boot-Update 작업을 사용하도록 설정합니다. try { $sysvolPath = "\\$($env:USERDNSDOMAIN)\SYSVOL\$($env:USERDNSDOMAIN)\Policies\{$($gpo. Id)}\Machine\Preferences\ScheduledTasks" if (-not (Test-Path $sysvolPath)) { New-Item -ItemType 디렉터리 -경로 $sysvolPath -Force | Out-Null } # PowerShell 명령을 사용하여 Secure-Boot-Update 작업을 사용하도록 설정 $enableCommand = 'schtasks.exe /Change /TN "\Microsoft\Windows\PI\Secure-Boot-Update" /ENABLE 2>$null; if ($LASTEXITCODE -ne 0) { Get-ScheduledTask -TaskPath "\Microsoft\Windows\PI\" -TaskName "Secure-Boot-Update" -ErrorAction SilentlyContinue | Enable-ScheduledTask }' 안전한 XML 포함을 위한 # 인코딩 명령 $encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($enableCommand)) $taskGuid = [guid]::NewGuid(). ToString("B"). ToUpper() # GPP 예약된 작업 XML - 한 번 실행되는 즉시 작업 $gppTaskXml = @" <?xml version="1.0" encoding="utf-8"?> <ScheduledTasks clsid="{CC63F200-7309-4ba0-B154-A71CD118DBCC}"> <ImmediateTaskV2 clsid="{9756B581-76EC-4169-9AFC-0CA8D43ADB5F}" name="$TaskName" image="0" changed="$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" uid="$taskGuid" removePolicy="1" userContext="0"> <속성 action="C" name="$TaskName" runAs="NT AUTHORITY\SYSTEM" logonType="S4U"> <작업 버전="1.3"> <RegistrationInfo> <설명>$TaskDescription</Description> </RegistrationInfo> <보안 주체> <보안 주체 id="Author"> <UserId>S-1-5-18</UserId> <RunLevel>HighestAvailable</RunLevel> </Principal> </Principals> <설정> <IdleSettings> <기간>PT5M</duration> <WaitTimeout>PT1H</WaitTimeout> <StopOnIdleEnd>false</StopOnIdleEnd> <RestartOnIdle>false</RestartOnIdle> </IdleSettings> <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy> <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries> <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries> <AllowHardTerminate>true</AllowHardTerminate> <StartWhenAvailable>true</StartWhenAvailable> AllowStartOnDemand>true</AllowStartOnDemand>< <사용>true</Enabled> <숨김>false</Hidden> <ExecutionTimeLimit>PT1H</ExecutionTimeLimit> <우선 순위>7</우선 순위> <DeleteExpiredTaskAfter>PT0S</DeleteExpiredTaskAfter> </설정> <작업> <Exec> <명령>powershell.exe</Command> <인수>-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedCommand</arguments> </Exec> </Actions> </Task> </Properties> </ImmediateTaskV2> </ScheduledTasks> "@ $gppTaskXml | Out-File -FilePath(Join-Path $sysvolPath "ScheduledTasks.xml") -Encoding UTF8 -Force "GPO에 일회성 예약 작업 배포: $TaskName" "확인" Write-Log } catch { Write-Log "예약된 작업 XML을 배포하지 못했습니다. $($_) Exception.Message)" "ERROR" return @{ Success = $false; 오류 = $_. Exception.Message } } # 3단계: 보안 그룹 만들기 또는 가져오기 $group = Get-ADGroup -Filter "Name -eq '$SecurityGroupName'" -ErrorAction SilentlyContinue if (-not $group) { try { $group = New-ADGroup -Name $SecurityGroupName ' -GroupCategory Security ' -GroupScope DomainLocal ' -Description "Secure-Boot-Update 작업이 비활성화된 컴퓨터 - 수정 대상" ' -Passthru Write-Log "만든 보안 그룹: $SecurityGroupName" "확인" } catch { Write-Log "보안 그룹을 만들지 못했습니다. $($_) Exception.Message)" "ERROR" return @{ Success = $false; 오류 = $_. Exception.Message } } } else { Write-Log "보안 그룹 존재: $SecurityGroupName" "INFO" } # 4단계: 보안 그룹에 컴퓨터 추가 $added = 0 $failed = 0 foreach($TargetHostnames $hostname) { try { $computer = Get-ADComputer -ID $hostname -ErrorAction 중지 Add-ADGroupMember -ID $SecurityGroupName -멤버 $computer -ErrorAction SilentlyContinue $added++ } catch { $failed++ Write-Log "AD에서 컴퓨터를 찾을 수 없음: $hostname" "WARN" } } Write-Log "보안 그룹에 $added 컴퓨터 추가(AD에서 찾을 $failed 없음)" "확인" # 5단계: GPO에서 보안 필터링 구성 try { Set-GPPermission -Name $GPOName -TargetName "인증된 사용자" -TargetType 그룹 -PermissionLevel GpoRead -Replace -ErrorAction SilentlyContinue Set-GPPermission -Name $GPOName -TargetName $SecurityGroupName -TargetType Group -PermissionLevel GpoApply -ErrorAction Stop Write-Log "구성한 보안 필터링: $SecurityGroupName" "확인" } catch { Write-Log "보안 필터링을 구성하지 못했습니다. $($_) Exception.Message)" "WARN" } # 6단계: GPO를 OU에 연결 if ($TargetOU) { try { $existingLink = Get-GPInheritance -Target $TargetOU -ErrorAction SilentlyContinue | Select-Object -ExpandProperty GpoLinks | Where-Object { $_. DisplayName -eq $GPOName } if (-not $existingLink) { New-GPLink -이름 $GPOName -대상 $TargetOU -LinkEnabled 예 -ErrorAction 중지 "연결된 GPO: $TargetOU" "확인" Write-Log } else { "대상 OU에 이미 연결된 GPO" "INFO" Write-Log } } catch { Write-Log "GPO를 OU에 연결하지 못했습니다. $($_) Exception.Message)" "ERROR" return @{ Success = $false; 오류 = "GPO 링크 실패: $($_. Exception.Message)" } } } else { Write-Log "TargetOU가 지정되지 않음 - GPO를 수동으로 연결해야 합니다." "WARN" } Write-Log "" "INFO" Write-Log "작업 배포 완료 사용" "확인" Write-Log "디바이스는 다음 GPO 새로 고침(gpupdate)에서 사용 작업을 실행합니다." "INFO" Write-Log "작업이 SYSTEM으로 한 번 실행되고 Secure-Boot-Update를 사용하도록 설정" "INFO" Write-Log "" "INFO" return @{ 성공 = $true ComputersAdded = $added ComputersFailed = $failed GPOName = $GPOName SecurityGroup = $SecurityGroupName } }
# ============================================================================ # 비활성화된 디바이스에서 작업 사용 # ============================================================================ if ($EnableTaskOnDisabled) { Write-Host "" Write-Host ("=" * 70) -ForegroundColor Yellow Write-Host "작업 수정 사용 - 비활성 예약된 작업 수정" -ForegroundColor Yellow Write-Host ("=" * 70) -ForegroundColor Yellow Write-Host "" # 집계 데이터에서 비활성화된 작업이 있는 디바이스 찾기 if (-not $AggregationInputPath) { Write-Host "오류: -AggregationInputPath는 비활성화된 작업으로 디바이스를 식별하는 데 필요합니다." -ForegroundColor Red Write-Host "Usage: .\Start-SecureBootRolloutOrchestrator.ps1 -EnableTaskOnDisabled -AggregationInputPath <path> -ReportBasePath <path>" -ForegroundColor Gray 종료 1 } Write-Host "Secure-Boot-Update 작업이 비활성화된 디바이스에 대한 검사..." -ForegroundColor Cyan # JSON 파일 로드 및 비활성화된 작업으로 디바이스 찾기 $jsonFiles = Get-ChildItem -Path $AggregationInputPath -Filter "*.json" -Recurse -ErrorAction SilentlyContinue | Where-Object { $_. Name -notmatch "ScanHistory|RolloutState|RolloutPlan" } $disabledTaskDevices = @() foreach($jsonFiles $file) { try { $device = Get-Content $file. FullName -Raw | ConvertFrom-Json if($device. SecureBootTaskEnabled -eq $false -or $device. SecureBootTaskStatus -eq 'Disabled' -or $device. SecureBootTaskStatus -eq 'NotFound') { # 아직 업데이트되지 않은 디바이스만 포함(이벤트 1808 없음) if ([int]$device. Event1808Count -eq 0) { $disabledTaskDevices += $device. 호스트 } } } catch { # 잘못된 파일 건너뛰기 } } $disabledTaskDevices = $disabledTaskDevices | Select-Object -Unique if ($disabledTaskDevices.Count -eq 0) { Write-Host "" Write-Host "Secure-Boot-Update 작업이 비활성화된 디바이스를 찾을 수 없습니다." -ForegroundColor Green Write-Host "모든 디바이스가 작업을 사용하도록 설정했거나 이미 업데이트되었습니다." -ForegroundColor Gray 종료 0 } Write-Host "" Write-Host "비활성화된 작업이 있는 $($disabledTaskDevices.Count) 디바이스를 찾았습니다."" -ForegroundColor Yellow $disabledTaskDevices | Select-Object -First 20 | ForEach-Object { Write-Host " - $_" -ForegroundColor Gray } if ($disabledTaskDevices.Count -gt 20) { Write-Host " ... 및 $($disabledTaskDevices.Count - 20) more" -ForegroundColor Gray } Write-Host "" # 작업 GPO 사용 배포 $result = Deploy-EnableTaskGPO -TargetHostnames $disabledTaskDevices -TargetOU $TargetOU -DryRun $DryRun if($result. 성공) { Write-Host "" Write-Host "SUCCESS: 배포된 작업 GPO 사용" -ForegroundColor Green Write-Host " 보안 그룹에 추가된 컴퓨터: $($result. ComputersAdded)" -ForegroundColor Cyan if($result. ComputersFailed -gt 0) { Write-Host " AD에서 컴퓨터를 찾을 수 없음: $($result. ComputersFailed)" -ForegroundColor 노란색 } Write-Host "" Write-Host "다음 단계:" -ForegroundColor White Write-Host " 1. 디바이스는 다음 새로 고침 시 GPO를 받습니다(gpupdate /force)" -ForegroundColor Gray Write-Host " 2. 일회성 작업은 Secure-Boot-Update를 사용하도록 설정합니다. -ForegroundColor Gray Write-Host " 3. 이제 작업이 사용하도록 설정되었는지 확인하기 위해 집계를 다시 실행합니다." -ForegroundColor Gray } else { Write-Host "" Write-Host "실패: 작업 GPO를 사용하도록 설정할 수 없음" -ForegroundColor Red Write-Host "오류: $($result. 오류)" -ForegroundColor Red } 종료 0 }
# ============================================================================ # 기본 오케스트레이션 루프 # ============================================================================
Write-Host "" Write-Host ("=" * 80) -ForegroundColor Cyan Write-Host "보안 부팅 롤아웃 오케스트레이터 - 지속적인 배포" -ForegroundColor Cyan Write-Host ("=" * 80) -ForegroundColor Cyan Write-Host ""
if ($DryRun) { Write-Host "[DRY RUN MODE]" -ForegroundColor Magenta }
if ($UseWinCS) { Write-Host "[WinCS MODE]" -ForegroundColor Yellow Write-Host "GPO/AvailableUpdatesPolicy 대신 WinCsFlags.exe 사용" -ForegroundColor Yellow Write-Host "WinCS 키: $WinCSKey" -ForegroundColor 회색 Write-Host "" }
Write-Log "Starting Secure Boot Rollout Orchestrator" "INFO" Write-Log "입력 경로: $AggregationInputPath" "INFO" Write-Log "보고서 경로: $ReportBasePath" "INFO" if ($UseWinCS) { Write-Log "배포 방법: WinCS(WinCsFlags.exe /apply --key '"$WinCSKey")" "INFO" } else { Write-Log "배포 방법: GPO(AvailableUpdatesPolicy)" "INFO" }
# Resolve TargetOU - default to domain root for domain-wide coverage # GPO 배포 방법에만 필요(WinCS에는 AD/GPO가 필요하지 않음) if (-not $UseWinCS -and -not $TargetOU) { try { # 여러 메서드를 사용하여 도메인 DN 가져오기 $domainDN = $null # 메서드 1: Get-ADDomain(RSAT-AD-PowerShell 필요) try { Import-Module ActiveDirectory -ErrorAction 중지 $domainDN = (Get-ADDomain -ErrorAction Stop). DistinguishedName } catch { Write-Log "Get-ADDomain 실패: $($_. Exception.Message)" "WARN" } # 방법 2: ADSI를 통해 RootDSE 사용 if (-not $domainDN) { try { $rootDSE = [ADSI]"LDAP://RootDSE" $domainDN = $rootDSE.defaultNamingContext.ToString() } catch { Write-Log "ADSI RootDSE 실패: $($_. Exception.Message)" "WARN" } } # 방법 3: 컴퓨터의 도메인 멤버 자격에서 구문 분석 if (-not $domainDN) { try { $domain = [System.DirectoryServices.ActiveDirectory.Domain]::GetComputerDomain() $domainDN = "DC=" + ($domain. 이름 -replace '\.', ',DC=') } catch { Write-Log "GetComputerDomain 실패: $($_. Exception.Message)" "WARN" } } if ($domainDN) { $TargetOU = $domainDN Write-Log "대상: 도메인 루트($domainDN) - GPO는 보안 그룹 필터링을 통해 도메인 전체에 적용됩니다" "INFO" } else { Write-Log "도메인 DN을 확인할 수 없음 - GPO가 만들어지지만 연결되지 않음!" "ERROR" Write-Log "생성 후 -TargetOU 매개 변수를 지정하거나 GPO를 수동으로 연결하세요" "ERROR" $TargetOU = $null } } catch { Write-Log "도메인 DN을 가져올 수 없음 - GPO가 만들어지지만 연결되지 않습니다. 필요한 경우 수동으로 연결합니다." "WARN" Write-Log "오류: $($_. Exception.Message)" "WARN" $TargetOU = $null } } else { Write-Log "대상 OU: $TargetOU" "INFO" }
Write-Log "Max Wait Hours: $MaxWaitHours" "INFO" Write-Log "폴링 간격: $PollIntervalMinutes 분" "INFO" if ($LargeScaleMode) { Write-Log "LargeScaleMode 사용(일괄 처리 크기: $ProcessingBatchSize, 로그 샘플: $DeviceLogSampleSize)" "INFO" }
# ============================================================================ # 필수 구성 요소 검사: 검색이 배포되고 작동하는지 확인 # ============================================================================
Write-Host "" Write-Log "필수 구성 요소 확인..." "INFO"
$detectionCheck = Test-DetectionGPODeployed -JsonPath $AggregationInputPath if (-not $detectionCheck.IsDeployed) { Write-Log $detectionCheck.Message "ERROR" Write-Host "" Write-Host "필수: 검색 인프라 먼저 배포:" -ForegroundColor Yellow Write-Host " 1. 실행: Deploy-GPO-SecureBootCollection.ps1 -OUPath 'OU=...' -OutputPath '\\server\SecureBootLogs$'" -ForegroundColor Cyan Write-Host " 2. 디바이스가 보고되기를 기다립니다(12-24시간)" -ForegroundColor Cyan Write-Host " 3. 이 오케스트레이터 다시 실행" -ForegroundColor Cyan Write-Host "" if (-not $DryRun) { 반환 } } else { Write-Log $detectionCheck.Message "OK" }
# Check data freshness $freshness = Get-DataFreshness -JsonPath $AggregationInputPath Write-Log "데이터 새로 고침: $($freshness. TotalFiles) 파일, $($freshness. FreshFiles) fresh (<24h), $($freshness. StaleFiles) 부실(>72h)" "INFO" if($freshness. 경고) { Write-Log $freshness. 경고 "WARN" }
# Load Allow List (targeted rollout - ONLY these devices will be rolled out) $allowedHostnames = @() if ($AllowListPath -또는 $AllowADGroup) { $allowedHostnames = Get-AllowedHostnames -AllowFilePath $AllowListPath -ADGroupName $AllowADGroup if ($allowedHostnames.Count -gt 0) { Write-Log "AllowList: ONLY $($allowedHostnames.Count) 디바이스는 롤아웃용으로 간주됩니다" "INFO" } else { Write-Log "AllowList가 지정되었지만 디바이스를 찾을 수 없으면 모든 롤아웃이 차단됩니다!" "WARN" } }
# Load VIP/exclusion list (BlockList) $excludedHostnames = @() if ($ExclusionListPath -또는 $ExcludeADGroup) { $excludedHostnames = Get-ExcludedHostnames -ExclusionFilePath $ExclusionListPath -ADGroupName $ExcludeADGroup if ($excludedHostnames.Count -gt 0) { Write-Log "VIP 제외: $($excludedHostnames.Count) 디바이스는 롤아웃에서 건너뜁니다." "INFO" } }
# Load state $rolloutState = Get-RolloutState $blockedBuckets = Get-BlockedBuckets $adminApproved = Get-AdminApproved $deviceHistory = Get-DeviceHistory
if ($rolloutState.Status -eq "NotStarted") { $rolloutState.Status = "InProgress" $rolloutState.StartedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Write-Log "새 롤아웃 시작" "WAVE" }
Write-Log "Current Wave: $($rolloutState.CurrentWave)" "INFO" Write-Log "차단된 버킷: $($blockedBuckets.Count)" "INFO"
# Main loop - runs until all eligible devices are updated $iterationCount = 0 while ($true) { $iterationCount++ Write-Host "" Write-Host ("=" * 80) -ForegroundColor White Write-Log "=== ITERATION $iterationCount ===" "WAVE" Write-Host ("=" * 80) -ForegroundColor White # 1단계: 집계 실행 Write-Log "1단계: 집계 실행 중..." "INFO" # Orchestrator는 항상 디스크 bloat을 방지하기 위해 단일 폴더(LargeScaleMode)를 다시 사용합니다. # 집계를 실행하는 관리자는 특정 시점 스냅샷에 대한 타임스탬프가 지정된 폴더를 수동으로 가져옵니다. $aggregationPath = Join-Path $ReportBasePath "Aggregation_Current" # 집계하기 전에 데이터 새로 고침 확인 $freshness = Get-DataFreshness -JsonPath $AggregationInputPath Write-Log "데이터 새로 고침: $($freshness. FreshFiles)/$($freshness. TotalFiles) 디바이스가 지난 24시간 동안 보고됨" "INFO" if($freshness. 경고) { Write-Log $freshness. 경고 "WARN" } $aggregateScript = Join-Path $ScriptRoot "Aggregate-SecureBootData.ps1" $scanHistoryPath = Join-Path $ReportBasePath "ScanHistory.json" $rolloutSummaryPath = Join-Path $stateDir "SecureBootRolloutSummary.json" if (Test-Path $aggregateScript) { if (-not $DryRun) { # 오케스트레이터는 항상 효율성을 위해 스트리밍 + 증분을 사용합니다. 최상의 성능을 위해 사용할 수 있는 경우 # Aggregator가 PS7로 자동 상승 $aggregateParams = @{ InputPath = $AggregationInputPath OutputPath = $aggregationPath StreamingMode = $true IncrementalMode = $true SkipReportIfUnchanged = $true ParallelThreads = 8 } #Pass rollout summary if it exists(속도/프로젝션 데이터용) if (Test-Path $rolloutSummaryPath) { $aggregateParams['RolloutSummaryPath'] = $rolloutSummaryPath } & $aggregateScript @aggregateParams # 디바이스 테이블을 사용하여 전체 HTML dashboard 생성하는 명령 표시 Write-Host "" Write-Host "제조업체/모델 테이블을 사용하여 전체 HTML dashboard 생성하려면 run:" -ForegroundColor Yellow Write-Host " $aggregateScript -InputPath '"$AggregationInputPath'" -OutputPath '"$aggregationPath'" -ForegroundColor Yellow Write-Host "" } else { Write-Log "[DRYRUN] 집계를 실행합니다" "INFO" # DryRun에서 ReportBasePath의 기존 집계 데이터를 직접 사용합니다. $aggregationPath = $ReportBasePath } } $rolloutState.LastAggregation = Get-Date -Format "yyyy-MM-dd HH:mm:ss" # 2단계: 현재 디바이스 상태 로드 Write-Log "2단계: 디바이스 상태 로드 중..." "INFO" $notUptodateCsv = Get-ChildItem -Path $aggregationPath -Filter "*NotUptodate*.csv" -ErrorAction SilentlyContinue | Where-Object { $_. 이름 -notlike "*Buckets*" } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $notUptodateCsv -and -not $DryRun) { Write-Log "집계 데이터를 찾을 수 없습니다. 대기 중..." "WARN" Start-Sleep -초($PollIntervalMinutes * 60) 계속 } $notUpdatedDevices = if ($notUptodateCsv) { Import-Csv $notUptodateCsv.FullName } else { @() } Write-Log "디바이스가 업데이트되지 않음: $($notUpdatedDevices.Count)" "INFO" $notUpdatedIndexes = Get-NotUpdatedIndexes -디바이스 $notUpdatedDevices # 3단계: 디바이스 기록 업데이트(호스트 이름별 추적) Write-Log "3단계: 디바이스 기록 업데이트..." "INFO" Update-DeviceHistory -CurrentDevices $notUpdatedDevices -DeviceHistory $deviceHistory Save-DeviceHistory -기록 $deviceHistory # 4단계: 차단된 버킷 확인(연결할 수 없는 디바이스) $existingBlockedCount = $blockedBuckets.Count Write-Log "4단계: 차단된 버킷 확인(대기 기간을 지난 디바이스 ping)..." "INFO" if ($existingBlockedCount -gt 0) { Write-Log "이전 실행에서 현재 차단된 버킷: $existingBlockedCount" "INFO" } if ($adminApproved.Count -gt 0) { Write-Log "관리 승인된 버킷(다시 차단되지 않음): $($adminApproved.Count)" "INFO" } $newlyBlocked = Update-BlockedBuckets -RolloutState $rolloutState -BlockedBuckets $blockedBuckets -AdminApproved $adminApproved -NotUpdatedDevices $notUpdatedDevices -NotUpdatedIndexes $notUpdatedIndexes -MaxWaitHours $MaxWaitHours -DryRun:$DryRun if ($newlyBlocked.Count -gt 0) { Save-BlockedBuckets 차단된 $blockedBuckets Write-Log "새로 차단된 버킷(이 반복): $($newlyBlocked.Count)" "BLOCKED" } # 4b단계: 디바이스가 업데이트된 버킷 자동 차단 해제 $autoUnblocked = Update-AutoUnblockedBuckets -BlockedBuckets $blockedBuckets -RolloutState $rolloutState -NotUpdatedDevices $notUpdatedDevices -ReportBasePath $ReportBasePath -NotUpdatedIndexes $notUpdatedIndexes -LogSampleSize $DeviceLogSampleSize if ($autoUnblocked.Count -gt 0) { Save-BlockedBuckets 차단된 $blockedBuckets Write-Log "자동 차단 해제 버킷(디바이스 업데이트): $($autoUnblocked.Count)" "확인" } # 5단계: 나머지 적격 디바이스 계산 $eligibleCount = 0 foreach($notUpdatedDevices $device) { $bucketKey = Get-BucketKey $device if (-not $blockedBuckets.Contains($bucketKey)) { $eligibleCount++ } } Write-Log "적격 디바이스 잔여: $eligibleCount" "INFO" Write-Log "차단된 버킷: $($blockedBuckets.Count)" "INFO" # 6단계: 완료 확인 if ($eligibleCount -eq 0) { Write-Log "롤아웃 완료 - 모든 적격 디바이스가 업데이트됨!" "OK" $rolloutState.Status = "Completed" $rolloutState.CompletedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Save-RolloutState -State $rolloutState 휴식 } # 6단계: 다음 웨이브 생성 및 배포 Write-Log "6단계: 롤아웃 웨이브 생성..." "INFO" $waveDevices = New-RolloutWave -AggregationPath $aggregationPath -BlockedBuckets $blockedBuckets -RolloutState $rolloutState -AllowedHostnames $allowedHostnames -ExcludedHostnames $excludedHostnames # 배포할 디바이스가 있는지 확인합니다($waveDevices $null, 비어 있거나 실제 디바이스가 있을 수 있음) $hasDevices = $waveDevices -및 @($waveDevices | Where-Object { $_ }). Count -gt 0 if ($hasDevices) { # 실제로 배포할 디바이스가 있는 경우에만 웨이브 번호 증가 $rolloutState.CurrentWave++ Write-Log "웨이브 $($rolloutState.CurrentWave): $(@($waveDevices). Count) devices" "WAVE" # 인라인 함수를 사용하여 GPO 배포 $gpoName = "${WavePrefix}-Wave$($rolloutState.CurrentWave)" $securityGroup = "${WavePrefix}-Wave$($rolloutState.CurrentWave)" $hostnames = @($waveDevices | ForEach-Object { if ($_. 호스트 이름) { $_. Hostname } elseif ($_. HostName) { $_. HostName } else { $null } } | Where-Object { $_ }) # 참조/감사를 위해 호스트 이름 파일 저장 $hostnamesFile = Join-Path $stateDir "Wave$($rolloutState.CurrentWave)_Hostnames.txt" $hostnames | Out-File $hostnamesFile -ENcoding UTF8 # 배포할 호스트 이름이 있는지 확인 if($hostnames. Count -eq 0) { Write-Log "웨이브 $($rolloutState.CurrentWave)에 유효한 호스트 이름이 없습니다. 디바이스에 Hostname 속성이 누락되었을 수 있음" "WARN" Write-Log "이 웨이브에 대한 배포 건너뛰기 - 디바이스 데이터 검사" "WARN" # 다음 반복 전에 계속 기다립니다. if (-not $DryRun) { Write-Log "다시 시도하기 전에 $PollIntervalMinutes 분 동안 자고..." "INFO" Start-Sleep -초($PollIntervalMinutes * 60) } 계속 } Write-Log "$($hostnames 배포. Count) Wave의 호스트 이름 $($rolloutState.CurrentWave)" "INFO" # -UseWinCS 매개 변수를 기반으로 WinCS 또는 GPO 메서드를 사용하여 배포 if ($UseWinCS) { # WinCS 메서드: 예약된 작업을 사용하여 GPO를 만들어 각 엔드포인트에서 WinCsFlags.exe SYSTEM으로 실행 Write-Log "WinCS 배포 방법 사용(키: $WinCSKey)" "WAVE" $wincsResult = Deploy-WinCSForWave -WaveHostnames $hostnames ' -WinCSKey $WinCSKey ' -WavePrefix $WavePrefix ' -WaveNumber $rolloutState.CurrentWave ' -TargetOU $TargetOU ' -DryRun:$DryRun if (-not $wincsResult.Success) { Write-Log "WinCS 배포에 실패 - 적용됨: $($wincsResult.Applied), 실패: $($wincsResult.Failed)" "WARN" } else { Write-Log "WinCS 배포 성공 - 적용됨: $($wincsResult.Applied), 건너뛰기: $($wincsResult.Skipped)" "확인" } # 감사용 WinCS 결과 저장 $wincsResultFile = "Wave$($rolloutState.CurrentWave)_WinCS_Results.json"Join-Path $stateDir $wincsResult | ConvertTo-Json -Depth 5 | Out-File $wincsResultFile -UTF8 인코딩 } else { # GPO 메서드: AvailableUpdatesPolicy 레지스트리 설정을 사용하여 GPO 만들기 $gpoResult = Deploy-GPOForWave -GPOName $gpoName -TargetOU $TargetOU -SecurityGroupName $securityGroup -WaveHostnames $hostnames -DryRun:$DryRun if (-not $gpoResult) { Write-Log "GPO 배포 실패 - 다음 반복을 다시 시도합니다" "ERROR" } } # 상태의 웨이브 기록 $waveRecord = @{ WaveNumber = $rolloutState.CurrentWave StartedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss" DeviceCount = @($waveDevices). 횟수 디바이스 = @($waveDevices | ForEach-Object { @{ Hostname = if ($_. 호스트 이름) { $_. Hostname } elseif ($_. HostName) { $_. HostName } else { $null } BucketKey = Get-BucketKey $_ } }) } # WaveHistory가 추가하기 전에 항상 배열인지 확인합니다(해시 가능한 병합 문제를 방지) $rolloutState.WaveHistory = @($rolloutState.WaveHistory) + @($waveRecord) $rolloutState.TotalDevicesTargeted += @($waveDevices). 횟수 Save-RolloutState -State $rolloutState Write-Log "Wave $($rolloutState.CurrentWave)가 배포되었습니다. $PollIntervalMinutes 분 대기 중..." "OK" } else { # 업데이트를 기다리는 배포된 디바이스의 상태 표시 Write-Log "" "INFO" Write-Log "배포된 모든 디바이스 ========== - 상태 ========== 대기 중" "정보" # 웨이브 기록에서 배포된 모든 디바이스 가져오기 $allDeployedLookup = @{} foreach($rolloutState.WaveHistory의 $wave) { foreach($wave $device. 디바이스) { if($device. 호스트 이름) { $allDeployedLookup[$device. 호스트 이름] = @{ 호스트 이름 = $device. 호스트 BucketKey = $device. BucketKey DeployedAt = $wave. StartedAt WaveNumber = $wave. WaveNumber } } } } $allDeployedDevices = @($allDeployedLookup.Values) if ($allDeployedDevices.Count -gt 0) { # 아직 보류 중인 배포된 디바이스 찾기(NotUpdated 목록) $stillPendingCount = 0 $noLongerPendingCount = 0 $pendingSample = @() foreach($allDeployedDevices $deployed) { if ($notUpdatedIndexes.HostSet.Contains($deployed. 호스트 이름)) { $stillPendingCount++ if ($pendingSample.Count -lt $DeviceLogSampleSize) { $pendingSample += $deployed. 호스트 } } else { $noLongerPendingCount++ } } # 집계에서 실제 업데이트된 개수 가져오기 - 이벤트 1808 및 UEFICA2023Status 구분 $summaryCsv = Get-ChildItem -Path $aggregationPath -Filter "*Summary*.csv" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $actualUpdated = 0 $totalDevicesFromSummary = 0 $event 1808Count = 0 $uefiStatusUpdated = 0 $needsRebootSample = @() if ($summaryCsv) { $summary = Import-Csv $summaryCsv.FullName | Select-Object -First 1 if($summary. 업데이트됨) { $actualUpdated = [int]$summary. 업데이트됨 } if($summary. TotalDevices) { $totalDevicesFromSummary = [int]$summary. TotalDevices } } # 웨이브 기록에서 속도 계산(디바이스가 하루에 업데이트됨) $devicesPerDay = 0 if ($rolloutState.StartedAt -and $actualUpdated -gt 0) { $startDate = [datetime]::P arse($rolloutState.StartedAt) $daysElapsed = ((Get-Date) - $startDate). TotalDays if ($daysElapsed -gt 0) { $devicesPerDay = $actualUpdated/$daysElapsed } } # 주말 인식 프로젝션을 사용하여 롤아웃 요약 저장 # 일관성을 위해 집계의 NotUptodate 개수(SB OFF 디바이스 제외) 사용 $notUpdatedCount = if($summary 및 $summary. NotUptodate) { [int]$summary. NotUptodate } else { $totalDevicesFromSummary - $actualUpdated } Save-RolloutSummary -State $rolloutState ' -TotalDevices $totalDevicesFromSummary ' -UpdatedDevices $actualUpdated ' -NotUpdatedDevices $notUpdatedCount ' -DevicesPerDay $devicesPerDay # UEFICA2023Status=업데이트되었지만 이벤트 1808이 없는 디바이스의 원시 데이터 확인(다시 부팅 필요) $dataFiles = Get-ChildItem -Path $AggregationInputPath -Filter "*.json" -ErrorAction SilentlyContinue $totalDataFiles = @($dataFiles). 횟수 $batchSize = [Math]::Max(500, $ProcessingBatchSize) if ($LargeScaleMode) { $batchSize = [Math]::Max(2000, $ProcessingBatchSize) }
if ($totalDataFiles -gt 0) { ($idx = 0, $idx -lt $totalDataFiles, $idx += $batchSize) { $end = [Math]::Min($idx + $batchSize - 1, $totalDataFiles - 1) $batchFiles = $dataFiles[$idx.. $end]
foreach ($file in $batchFiles) { try { $deviceData = Get-Content $file. FullName -Raw | ConvertFrom-Json $hostname = $deviceData.Hostname if (-not $hostname) { continue } $has 1808 = [int]$deviceData.Event1808Count -gt 0 $hasUefiUpdated = $deviceData.UEFICA2023Status -eq "Updated" if ($has 1808) { $event 1808Count++ } elseif ($hasUefiUpdated) { $uefiStatusUpdated++ if ($needsRebootSample.Count -lt $DeviceLogSampleSize) { $needsRebootSample += $hostname } } } catch { } }
Save-ProcessingCheckpoint -Stage "RebootStatusScan" -Processed ($end + 1) -Total $totalDataFiles -Metrics @{ Event1808Count = $event 1808Count UEFIUpdatedAwaitingReboot = $uefiStatusUpdated } } } Write-Log "배포된 총액: $($allDeployedDevices.Count)" "INFO" Write-Log "업데이트됨(이벤트 1808 확인됨): $event 1808Count" "OK" if ($uefiStatusUpdated -gt 0) { Write-Log "업데이트됨(UEFICA2023Status=업데이트됨, 다시 부팅 대기 중): $uefiStatusUpdated" "확인" $rebootSuffix = if ($uefiStatusUpdated -gt $DeviceLogSampleSize) { " ... (+$($uefiStatusUpdated - $DeviceLogSampleSize)" } else { "" } Write-Log " 이벤트 1808에 다시 부팅해야 하는 디바이스(샘플): $($needsRebootSample -join ', ')$rebootSuffix" "INFO" Write-Log "이러한 디바이스는 다음 재부팅 후 이벤트 1808을 보고합니다" "INFO" } Write-Log "더 이상 보류 중이 아닙니다: $noLongerPendingCount(SecureBoot OFF 포함, 디바이스 누락)" "INFO" Write-Log "대기 상태: $stillPendingCount" "정보" if ($stillPendingCount -gt 0) { $pendingSuffix = if ($stillPendingCount -gt $DeviceLogSampleSize) { " ... (+$($stillPendingCount - $DeviceLogSampleSize)" } else { "" } Write-Log "보류 중인 디바이스(샘플): $($pendingSample -join ', ')$pendingSuffix" "WARN" } } else { Write-Log "아직 배포된 디바이스가 없음" "INFO" } Write-Log "================================================================" "INFO" Write-Log "" "INFO" } # 다음 반복 전에 대기 if (-not $DryRun) { Write-Log "$PollIntervalMinutes 분 동안 자고..." "INFO" Start-Sleep -초($PollIntervalMinutes * 60) } else { Write-Log "[DRYRUN] $PollIntervalMinutes 분을 기다릴 것" "정보" break # Dry Run에서 한 번 반복한 후 종료 } }
# ============================================================================ # 최종 요약 # ============================================================================
Write-Host "" Write-Host ("=" * 80) -ForegroundColor Green Write-Host "ROLLOUT ORCHESTRATOR SUMMARY" -ForegroundColor Green Write-Host ("=" * 80) -ForegroundColor Green Write-Host ""
$finalState = Get-RolloutState $finalBlocked = Get-BlockedBuckets
Write-Host "Status: $($finalState.Status)" -ForegroundColor $(if ($finalState.Status -eq "Completed") { "Green" } else { "Yellow" }) Write-Host "총 웨이브: $($finalState.CurrentWave)" Write-Host "대상 디바이스: $($finalState.TotalDevicesTargeted)" Write-Host "차단된 버킷: $($finalBlocked.Count)" -ForegroundColor $(if ($finalBlocked.Count -gt 0) { "Red" } else { "Green" }) Write-Host "디바이스 추적: $($deviceHistory.Count)" -ForegroundColor Gray Write-Host ""
if ($finalBlocked.Count -gt 0) { Write-Host "차단된 버킷(수동 검토 필요):" -ForegroundColor Red foreach($finalBlocked.Keys의 $key) { $info = $finalBlocked[$key] Write-Host " - $key" -ForegroundColor Red Write-Host " 이유: $($info. Reason)" -ForegroundColor Gray } Write-Host "" Write-Host "차단된 버킷 파일: $blockedBucketsPath" -ForegroundColor Yellow }
Write-Host "" Write-Host "상태 파일:" -ForegroundColor Cyan Write-Host "롤아웃 상태: $rolloutStatePath" Write-Host "차단된 버킷: $blockedBucketsPath" Write-Host " 디바이스 기록: $deviceHistoryPath" Write-Host ""