chore: 저장소 구조 정리 및 문서화, 첫 커밋
- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes) - 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에 안내용 README.md 추가 - CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가 - docs/README.md 문서 지도 갱신
This commit is contained in:
commit
56a6e2da93
159 changed files with 145825 additions and 0 deletions
509
scripts/install_tasks.ps1
Normal file
509
scripts/install_tasks.ps1
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
#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
|
||||
Loading…
Add table
Add a link
Reference in a new issue