#Requires -Version 5.1 <# .SYNOPSIS DMF Crawler 작업 스케줄러 작업 3종(Daily / Agent / AgyUpdate)을 등록한다. .DESCRIPTION 멱등(idempotent)하다. 같은 이름의 작업이 이미 있으면 지우고 다시 만든다. 관리자 권한 PowerShell 에서 실행해야 한다(RunLevel Highest 등록에 필요 — 사용자 확정: 관리자 승격은 설치 시점 1회만 요구한다). 트리거 3종(사용자 확정 사항, ops/01-scheduling-and-resilience.md §1.4): ① 매일 지정 시각(기본 06:00, RandomDelay 로 지터) — schedule.enable_daily_trigger ② 부팅 후 캐치업(StartWhenAvailable + AtStartup, RestartCount 재시도 포함) — schedule.enable_missed_task_catchup ③ 로그온 시 알림 에이전트 — schedule.enable_logon_trigger 이 세 토글은 config.toml [schedule] 섹션과 이름을 맞췄다. 스위치를 끄면 해당 트리거만 빠지고 나머지는 그대로 등록된다. 배치(DMF_Crawler_Daily)와 UI 를 띄우는 에이전트(DMF_Crawler_Agent)를 분리한다(ADR-10) — S4U 세션에는 데스크톱이 없어 토스트·모달을 띄울 수 없기 때문이다. 알림을 "발생"시키는 주체와 "표시"하는 주체가 다르다. .PARAMETER ProjectRoot 프로젝트 루트. 생략하면 이 스크립트 위치의 상위 폴더. .PARAMETER TaskPath 작업 스케줄러 폴더. 기본 '\DMF_Crawler\' (아키텍처 §4 데이터 흐름의 DMF_Crawler_Daily 표기와 일치시킨다). .PARAMETER Time schedule.daily_time 대응. 기본 06:00. .PARAMETER User 작업을 실행할 계정. 기본은 현재 로그인 계정. .PARAMETER LogonType S4U(기본, 암호 저장 안 함) | Password(DPAPI 복호화가 S4U 에서 실패할 때) | Interactive('Logon as Batch' 권한이 없는 계정의 최후 폴백). .PARAMETER JitterSeconds schedule.jitter_seconds 대응. 0~300초. .PARAMETER StartupDelayMinutes schedule.startup_delay_minutes 대응. 부팅 트리거 지연. .PARAMETER ExecutionTimeLimitMinutes schedule.execution_time_limit_minutes 대응. .PARAMETER RestartCount schedule.restart_count 대응. .PARAMETER RestartIntervalMinutes schedule.restart_interval_minutes 대응. .PARAMETER AgentRepeatMinutes schedule.agent_repeat_minutes 대응. 알림 에이전트 반복 주기. .PARAMETER AgyUpdateWeekday schedule.agy_update_weekday 대응. .PARAMETER AgyUpdateTime schedule.agy_update_time 대응. .PARAMETER EnableDailyTrigger schedule.enable_daily_trigger 대응. 끄면 매일 06:00 자동 실행이 빠진다 (수동 실행·부팅 캐치업은 별개로 남는다). .PARAMETER EnableLogonTrigger schedule.enable_logon_trigger 대응. 끄면 로그온 즉시 실행이 빠진다 (15분 반복 자체는 남는다 — 로그온한 세션이 있어야 어차피 돈다). .PARAMETER EnableMissedTaskCatchup schedule.enable_missed_task_catchup 대응. 끄면 AtStartup 트리거와 StartWhenAvailable/WakeToRun 이 빠진다. .PARAMETER PreventConcurrentRuns schedule.prevent_concurrent_runs 대응. 끄면 스케줄러 레벨의 MultipleInstances 가 IgnoreNew 대신 Parallel 이 된다(코드의 idempotency 가드·run.lock 은 이 스위치와 무관하게 항상 동작한다 — 3중 방어의 나머지 2겹). .PARAMETER SkipAgyUpdateTask agy.exe 가 없을 때 자동으로 건너뛰지만, 있어도 강제로 건너뛰고 싶을 때. .PARAMETER Verify 등록 직후 보안 컨텍스트 프로브(`dmf_crawler doctor --json`)를 1회 실행해 DPAPI 복호화가 이 LogonType 에서 되는지 실측한다. .EXAMPLE powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify .EXAMPLE # DPAPI 복호화가 S4U 에서 실패했을 때 powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -LogonType Password -Verify #> [CmdletBinding()] param( [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot), [string]$TaskPath = '\DMF_Crawler\', [string]$Time = '06:00', [string]$User = "$env:USERDOMAIN\$env:USERNAME", [ValidateSet('S4U', 'Password', 'Interactive')] [string]$LogonType = 'S4U', [System.Security.SecureString]$Password, [ValidateRange(0, 300)] [int]$JitterSeconds = 240, [ValidateRange(0, 120)] [int]$StartupDelayMinutes = 5, [ValidateRange(5, 720)] [int]$ExecutionTimeLimitMinutes = 30, [ValidateRange(0, 10)] [int]$RestartCount = 3, [ValidateRange(1, 120)] [int]$RestartIntervalMinutes = 10, [ValidateRange(1, 1440)] [int]$AgentRepeatMinutes = 15, [ValidateSet('Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday')] [string]$AgyUpdateWeekday = 'Sunday', [string]$AgyUpdateTime = '14:00', [bool]$EnableDailyTrigger = $true, [bool]$EnableLogonTrigger = $true, [bool]$EnableMissedTaskCatchup = $true, [bool]$PreventConcurrentRuns = $true, [switch]$SkipAgyUpdateTask, [switch]$Verify ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ---------------------------------------------------------------- 유틸 function Write-Step { param([string]$Message) Write-Host "[install_tasks] $Message" } function Write-Warn { param([string]$Message) Write-Host "[install_tasks] ! $Message" -ForegroundColor Yellow } function Write-Good { param([string]$Message) Write-Host "[install_tasks] + $Message" -ForegroundColor Green } function Assert-Administrator { $id = [Security.Principal.WindowsIdentity]::GetCurrent() $pr = [Security.Principal.WindowsPrincipal]::new($id) if (-not $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw '관리자 권한 PowerShell 에서 실행하세요. (시작 → PowerShell 우클릭 → 관리자 권한으로 실행)' } } function ConvertTo-PlainText { param([System.Security.SecureString]$Secure) if (-not $Secure) { return $null } $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } function Get-TimeOfDay { param([string]$Text, [string]$Label) $parsed = [datetime]::MinValue $ok = [datetime]::TryParseExact( $Text, 'HH:mm', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$parsed) if (-not $ok) { throw "$Label 형식이 잘못됐습니다: '$Text' (HH:mm 이어야 합니다)" } return (Get-Date).Date.AddHours($parsed.Hour).AddMinutes($parsed.Minute) } # ---------------------------------------------------------------- 0. 사전 점검 Assert-Administrator $ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path $PythonExe = Join-Path $ProjectRoot '.venv\Scripts\python.exe' $PythonwExe = Join-Path $ProjectRoot '.venv\Scripts\pythonw.exe' $AgyExe = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe' Write-Step "프로젝트 루트 : $ProjectRoot" Write-Step "실행 계정 : $User (LogonType=$LogonType)" foreach ($exe in @($PythonExe, $PythonwExe)) { if (-not (Test-Path -LiteralPath $exe)) { throw "가상환경 실행 파일이 없습니다: $exe`n → bootstrap.cmd 를 먼저 실행하세요." } } if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'config\config.toml'))) { Write-Warn 'config\config.toml 이 없습니다. 등록은 진행하지만 첫 실행은 종료 코드 2(BLOCKED)로 끝납니다.' } foreach ($dir in @('state', 'logs', 'reports', 'data')) { $p = Join-Path $ProjectRoot $dir if (-not (Test-Path -LiteralPath $p)) { New-Item -ItemType Directory -Path $p | Out-Null } } $plainPassword = $null if ($LogonType -eq 'Password') { if (-not $Password) { $Password = Read-Host -AsSecureString "«$User» 계정의 Windows 로그인 암호" } $plainPassword = ConvertTo-PlainText -Secure $Password if ([string]::IsNullOrEmpty($plainPassword)) { throw '암호가 비어 있습니다.' } } $multipleInstances = if ($PreventConcurrentRuns) { 'IgnoreNew' } else { 'Parallel' } # ---------------------------------------------------------------- 1. 작업 기록(History) 채널 활성화 # 기본적으로 꺼져 있다. 꺼져 있으면 "기록" 탭이 비고 사후 진단이 불가능하다. Write-Step '작업 스케줄러 Operational 로그 활성화' try { & wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /enabled:true /quiet & wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /maxsize:67108864 if ($LASTEXITCODE -ne 0) { Write-Warn "wevtutil 이 $LASTEXITCODE 로 끝났습니다. 기록 없이 진행합니다." } } catch { Write-Warn "wevtutil 호출 실패(무해): $($_.Exception.Message)" } # ---------------------------------------------------------------- 2. 공통 등록 함수 function Register-DmfTask { <# $Trigger 가 빈 배열이면 트리거 없이(수동 실행 전용) 등록한다 — -Trigger 파라미터에 빈 배열을 넘기면 오류가 나므로 조건부로 뺀다. #> param( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)]$Action, [AllowEmptyCollection()][array]$Trigger = @(), [Parameter(Mandatory)]$Settings, [Parameter(Mandatory)]$Principal, [Parameter(Mandatory)][string]$Description, [string]$PlainPassword ) $existing = Get-ScheduledTask -TaskName $Name -TaskPath $Path -ErrorAction SilentlyContinue if ($existing) { Write-Step "기존 작업 제거: $Path$Name" Unregister-ScheduledTask -TaskName $Name -TaskPath $Path -Confirm:$false } $newTaskArgs = @{ Action = $Action Settings = $Settings Principal = $Principal Description = $Description } if ($Trigger.Count -gt 0) { $newTaskArgs['Trigger'] = $Trigger } $definition = New-ScheduledTask @newTaskArgs if ($PlainPassword) { Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition ` -User $Principal.UserId -Password $PlainPassword | Out-Null } else { Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition | Out-Null } Write-Good "등록 완료: $Path$Name" } # ---------------------------------------------------------------- 3. ① DMF_Crawler_Daily Write-Step '① DMF_Crawler_Daily 구성' $actionDaily = New-ScheduledTaskAction ` -Execute $PythonExe ` -Argument '-m dmf_crawler run --trigger scheduled' ` -WorkingDirectory $ProjectRoot $dailyTriggers = @() if ($EnableDailyTrigger) { $dailyAt = Get-TimeOfDay -Text $Time -Label 'schedule.daily_time' $trgDaily = New-ScheduledTaskTrigger -Daily -At $dailyAt ` -RandomDelay (New-TimeSpan -Seconds $JitterSeconds) $dailyTriggers += $trgDaily } else { Write-Warn 'schedule.enable_daily_trigger=false → 매일 자동 실행 트리거를 등록하지 않습니다.' } if ($EnableMissedTaskCatchup) { # AtStartup 트리거에는 -Delay 파라미터가 없다. CIM 인스턴스 속성을 직접 채운다. $trgBoot = New-ScheduledTaskTrigger -AtStartup $trgBoot.Delay = "PT${StartupDelayMinutes}M" $dailyTriggers += $trgBoot } else { Write-Warn 'schedule.enable_missed_task_catchup=false → 부팅 캐치업 트리거를 등록하지 않습니다.' } $setDaily = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable:$EnableMissedTaskCatchup ` -DontStopOnIdleEnd ` -RunOnlyIfNetworkAvailable ` -ExecutionTimeLimit (New-TimeSpan -Minutes $ExecutionTimeLimitMinutes) ` -RestartCount $RestartCount ` -RestartInterval (New-TimeSpan -Minutes $RestartIntervalMinutes) ` -MultipleInstances $multipleInstances ` -Priority 5 ` -Compatibility Win8 if ($EnableMissedTaskCatchup) { $setDaily.WakeToRun = $true } $prcDaily = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest Register-DmfTask ` -Name 'DMF_Crawler_Daily' ` -Path $TaskPath ` -Action $actionDaily ` -Trigger $dailyTriggers ` -Settings $setDaily ` -Principal $prcDaily ` -Description "DMF 일일 수집·비교·리포트 배치. 매일 $Time + 부팅 후 ${StartupDelayMinutes}분. UI 를 띄우지 않는다." ` -PlainPassword $plainPassword # ---------------------------------------------------------------- 4. ② DMF_Crawler_Agent Write-Step '② DMF_Crawler_Agent 구성' $actionAgent = New-ScheduledTaskAction ` -Execute $PythonwExe ` -Argument '-m dmf_crawler notify-pump --once' ` -WorkingDirectory $ProjectRoot # 15분 반복은 항상 켠다 — 로그온 세션이 있어야만 Interactive 작업이 도니까 # "로그온 여부와 무관하게 반복 트리거를 심어 둔다"가 안전한 기본값이다. $repeatStart = (Get-Date).AddMinutes(2) try { $trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart ` -RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) ` -RepetitionDuration ([TimeSpan]::MaxValue) } catch { # 일부 빌드에서 [TimeSpan]::MaxValue 가 거부된다. 10년으로 대체한다. Write-Warn 'RepetitionDuration=MaxValue 거부됨 → 3650일로 대체' $trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart ` -RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) ` -RepetitionDuration (New-TimeSpan -Days 3650) } $agentTriggers = @($trgRepeat) if ($EnableLogonTrigger) { $trgLogon = New-ScheduledTaskTrigger -AtLogOn -User $User # 로그온 트리거에도 같은 반복을 붙여 둔다(로그온 이후에도 계속 돌게). $trgLogon.Repetition = $trgRepeat.Repetition $agentTriggers += $trgLogon } else { Write-Warn 'schedule.enable_logon_trigger=false → 로그온 즉시 실행 트리거를 등록하지 않습니다(15분 반복은 유지).' } $setAgent = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -DontStopOnIdleEnd ` -ExecutionTimeLimit (New-TimeSpan -Minutes 10) ` -MultipleInstances $multipleInstances ` -Priority 7 ` -Compatibility Win8 # Interactive 는 로그온한 세션에서만 돈다 — 그것이 목적이다(UI 를 띄우는 유일한 작업). $prcAgent = New-ScheduledTaskPrincipal -UserId $User -LogonType Interactive -RunLevel Limited Register-DmfTask ` -Name 'DMF_Crawler_Agent' ` -Path $TaskPath ` -Action $actionAgent ` -Trigger $agentTriggers ` -Settings $setAgent ` -Principal $prcAgent ` -Description "DMF 알림 에이전트. 로그온 시 + ${AgentRepeatMinutes}분마다 heartbeat 를 점검하고 밀린 알림을 표시한다." # ---------------------------------------------------------------- 5. ③ DMF_Crawler_AgyUpdate if ($SkipAgyUpdateTask) { Write-Warn '③ DMF_Crawler_AgyUpdate 는 -SkipAgyUpdateTask 로 건너뜁니다.' } elseif (-not (Test-Path -LiteralPath $AgyExe)) { Write-Warn "agy.exe 를 찾을 수 없어 ③ 을 건너뜁니다: $AgyExe" Write-Warn ' → scripts\bootstrap_agy.ps1 실행 후 이 스크립트를 다시 돌리세요.' } else { Write-Step '③ DMF_Crawler_AgyUpdate 구성' $agyAt = Get-TimeOfDay -Text $AgyUpdateTime -Label 'schedule.agy_update_time' $actionAgy = New-ScheduledTaskAction ` -Execute $AgyExe ` -Argument 'update' ` -WorkingDirectory $ProjectRoot $trgAgy = New-ScheduledTaskTrigger -Weekly -WeeksInterval 1 ` -DaysOfWeek $AgyUpdateWeekday -At $agyAt ` -RandomDelay (New-TimeSpan -Minutes 10) $setAgy = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -RunOnlyIfNetworkAvailable ` -ExecutionTimeLimit (New-TimeSpan -Minutes 30) ` -MultipleInstances $multipleInstances ` -Priority 7 ` -Compatibility Win8 $prcAgy = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Limited Register-DmfTask ` -Name 'DMF_Crawler_AgyUpdate' ` -Path $TaskPath ` -Action $actionAgy ` -Trigger @($trgAgy) ` -Settings $setAgy ` -Principal $prcAgy ` -Description "agy CLI 주간 업데이트. 배치 시간대를 피해 $AgyUpdateWeekday $AgyUpdateTime 에 돈다." ` -PlainPassword $plainPassword } # ---------------------------------------------------------------- 6. 등록 결과 요약(검증 출력) Write-Host '' Write-Step '등록 결과' Get-ScheduledTask -TaskPath $TaskPath | Select-Object TaskName, State, @{ n = 'LogonType'; e = { $_.Principal.LogonType } }, @{ n = 'RunLevel'; e = { $_.Principal.RunLevel } }, @{ n = 'UserId'; e = { $_.Principal.UserId } } | Format-Table -AutoSize Get-ScheduledTask -TaskPath $TaskPath | ForEach-Object { $info = $_ | Get-ScheduledTaskInfo [pscustomobject]@{ TaskName = $_.TaskName NextRunTime = $info.NextRunTime LastRunTime = $info.LastRunTime LastTaskResult = ('0x{0:X}' -f $info.LastTaskResult) } } | Format-Table -AutoSize # 배터리·캐치업·중복방지 4종이 의도대로 뒤집혔는지 자동 검증(설치 직후 흔한 실수 방지). $dailyTask = Get-ScheduledTask -TaskName 'DMF_Crawler_Daily' -TaskPath $TaskPath -ErrorAction SilentlyContinue if ($dailyTask) { $s = $dailyTask.Settings $p = $dailyTask.Principal $expect = [ordered]@{ 'DisallowStartIfOnBatteries = False' = ($s.DisallowStartIfOnBatteries -eq $false) 'StopIfGoingOnBatteries = False' = ($s.StopIfGoingOnBatteries -eq $false) "MultipleInstances = $multipleInstances" = ($s.MultipleInstances -eq $multipleInstances) "RunLevel = Highest" = ($p.RunLevel -eq 'Highest') 'UserId != SYSTEM' = ($p.UserId -notmatch 'SYSTEM|LOCALSERVICE|NETWORKSERVICE') } Write-Host '' Write-Step 'DMF_Crawler_Daily 설정 검증' $fail = 0 foreach ($k in $expect.Keys) { if ($expect[$k]) { Write-Host " OK $k" -ForegroundColor Green } else { Write-Host " FAIL $k" -ForegroundColor Red; $fail++ } } if ($fail -gt 0) { Write-Warn "$fail 개 항목이 어긋났습니다. 이 스크립트를 다시 실행하세요." } else { Write-Good '전 항목 통과.' } } # ---------------------------------------------------------------- 7. 보안 컨텍스트 프로브 (-Verify) if ($Verify) { Write-Host '' Write-Step '보안 컨텍스트 프로브 시작 (DPAPI 복호화가 이 LogonType 에서 되는지 실측)' $probeName = 'DMF_Crawler_Probe' $probeOut = Join-Path $ProjectRoot 'state\probe.json' if (Test-Path -LiteralPath $probeOut) { Remove-Item -LiteralPath $probeOut -Force } # doctor --json 을 파일로 리다이렉트해야 하므로 cmd.exe 를 경유한다. $probeCmd = '/c ""{0}" -m dmf_crawler doctor --json > "{1}" 2>&1"' -f $PythonExe, $probeOut $actionProbe = New-ScheduledTaskAction -Execute $env:ComSpec -Argument $probeCmd -WorkingDirectory $ProjectRoot $setProbe = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -ExecutionTimeLimit (New-TimeSpan -Minutes 3) -MultipleInstances IgnoreNew -Compatibility Win8 $trgProbe = New-ScheduledTaskTrigger -Once -At (Get-Date).AddYears(10) # 자동 실행은 절대 안 함 $prcProbe = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest Register-DmfTask -Name $probeName -Path $TaskPath -Action $actionProbe -Trigger @($trgProbe) ` -Settings $setProbe -Principal $prcProbe -Description '일회성 보안 컨텍스트 프로브(자동 삭제)' ` -PlainPassword $plainPassword try { Start-ScheduledTask -TaskName $probeName -TaskPath $TaskPath $deadline = (Get-Date).AddMinutes(3) do { Start-Sleep -Seconds 2 $state = (Get-ScheduledTask -TaskName $probeName -TaskPath $TaskPath).State } while ($state -eq 'Running' -and (Get-Date) -lt $deadline) if (-not (Test-Path -LiteralPath $probeOut)) { Write-Warn '프로브가 출력을 남기지 못했습니다. 작업이 아예 시작되지 못했을 수 있습니다.' Write-Warn ' → Get-WinEvent -LogName "Microsoft-Windows-TaskScheduler/Operational" -MaxEvents 30 으로 확인' } else { $raw = Get-Content -LiteralPath $probeOut -Raw try { $doc = $raw | ConvertFrom-Json $bad = @($doc.checks | Where-Object { -not $_.ok }) if ($bad.Count -eq 0) { Write-Good "프로브 통과: LogonType=$LogonType 에서 모든 진단이 정상입니다." } else { Write-Warn "프로브 실패 항목 $($bad.Count) 개:" $bad | ForEach-Object { Write-Warn (" - [{0}] {1} : {2}" -f $_.key, $_.title, $_.detail) } if ($bad.key -contains 'api_key') { Write-Warn '' Write-Warn ' ★ api_key 체크가 실패했다면 DPAPI 복호화가 이 로그온 타입에서 막힌 것입니다.' Write-Warn ' 다음 명령으로 암호 저장 방식으로 다시 등록하세요:' Write-Warn " .\scripts\install_tasks.ps1 -LogonType Password -Verify" } } } catch { Write-Warn 'JSON 파싱 실패. 원문을 그대로 출력합니다:' Write-Host $raw } } } finally { Unregister-ScheduledTask -TaskName $probeName -TaskPath $TaskPath -Confirm:$false -ErrorAction SilentlyContinue Write-Step '프로브 작업 제거 완료' } } Write-Host '' Write-Good '작업 등록이 끝났습니다.' exit 0