WinBatchOrchestrator.psm1
|
# WinBatchOrchestrator.psm1 # Defaults to C:\Ops; OPS_ROOT overrides it for development and testing. $script:OpsRoot = if ([string]::IsNullOrWhiteSpace($env:OPS_ROOT)) { "C:\Ops" } else { $env:OPS_ROOT } # -Verbose/-ErrorAction/-WhatIf and friends cannot bind to the asset scripts' plain param() blocks. $script:CommonParameterNames = @( [System.Management.Automation.PSCmdlet]::CommonParameters + [System.Management.Automation.PSCmdlet]::OptionalCommonParameters ) function Test-OpsAdministrator { <# .SYNOPSIS Returns $true when the current session is elevated. #> try { $Identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() $Principal = New-Object System.Security.Principal.WindowsPrincipal($Identity) return $Principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } function Initialize-OrchestratorEnvironment { <# .SYNOPSIS Initializes the C:\Ops environment. .PARAMETER ServiceAccount The service account user to grant access to secrets. #> [CmdletBinding()] param ( [string]$ServiceAccount ) if (-not (Test-OpsAdministrator)) { Write-Warning "Not running elevated. Event log source creation, ACL hardening and scheduled task registration will likely fail. Re-run this command from an elevated PowerShell session." } $OpsRoot = $script:OpsRoot $Dirs = @("Bin", "Scripts", "Logs", "Secrets") foreach ($Dir in $Dirs) { $Path = Join-Path $OpsRoot $Dir if (-not (Test-Path $Path)) { New-Item -ItemType Directory -Path $Path -Force | Out-Null Write-Host "Created $Path" } } # Event Source Creation (Requires Admin) $EventSource = "WinBatchOrchestrator" try { if (-not ([System.Diagnostics.EventLog]::SourceExists($EventSource))) { New-EventLog -LogName Application -Source $EventSource Write-Host "Created Event Source: $EventSource" } } catch { Write-Warning "Could not check or create Event Source '$EventSource'. Ensure you are running as Administrator if this is the first run. Error: $_" } # Version Check & Asset Deployment $AssetsDir = Join-Path $PSScriptRoot "Assets" $VersionFile = Join-Path $OpsRoot "Bin\version.txt" # $ExecutionContext.SessionState.Module is always the module that owns this # function, unlike $MyInvocation.MyCommand.Module which depends on the caller. $ModuleVersion = $ExecutionContext.SessionState.Module.Version $CurrentVersion = if ($ModuleVersion) { $ModuleVersion.ToString() } else { "0.0.0" } $DeployedVersion = "0.0.0" if (Test-Path $VersionFile) { # -Raw keeps the trailing newline Set-Content writes; without Trim() the # comparison below never matches and assets redeploy on every call. $DeployedVersion = (Get-Content -Path $VersionFile -Raw) if ($null -ne $DeployedVersion) { $DeployedVersion = $DeployedVersion.Trim() } } if ($CurrentVersion -ne $DeployedVersion) { Write-Host "Upgrading Assets from $DeployedVersion to $CurrentVersion..." if (Test-Path $AssetsDir) { # Copy everything except Dashboard to Bin Get-ChildItem -Path $AssetsDir -Exclude "Dashboard" | Copy-Item -Destination (Join-Path $OpsRoot "Bin") -Recurse -Force # Deploy Dashboard $DashboardAsset = Join-Path $AssetsDir "Dashboard" $DashboardDest = Join-Path $OpsRoot "Dashboard" if (Test-Path $DashboardAsset) { if (-not (Test-Path $DashboardDest)) { New-Item -ItemType Directory -Path $DashboardDest -Force | Out-Null } Copy-Item -Path (Join-Path $DashboardAsset "*") -Destination $DashboardDest -Recurse -Force Write-Host "Deployed Dashboard to $DashboardDest" } Set-Content -Path $VersionFile -Value $CurrentVersion -Encoding UTF8 Write-Host "Deployed Assets to $OpsRoot\Bin" } else { Write-Warning "Assets directory not found at '$AssetsDir'. Nothing deployed." } } else { Write-Host "Assets are up to date ($CurrentVersion)." } # Templates belong in Scripts, not Bin. A redeploy copies them into Bin again, # so drop the Bin copy once the Scripts copy exists instead of leaving it behind. $ScriptsDir = Join-Path $OpsRoot "Scripts" $Templates = @(Get-ChildItem -Path (Join-Path $OpsRoot "Bin") -Filter "Template-*" -File -ErrorAction SilentlyContinue) foreach ($Template in $Templates) { $Dest = Join-Path $ScriptsDir $Template.Name if (Test-Path $Dest) { Remove-Item -Path $Template.FullName -Force } else { Move-Item -Path $Template.FullName -Destination $ScriptsDir -Force Write-Host "Moved Template $($Template.Name) to $ScriptsDir" } } # ops-utils.js is required by the Node.js template, which lives in Scripts. # Keep a copy next to the templates so `require('./ops-utils')` resolves. $OpsUtilsJs = Join-Path $OpsRoot "Bin\ops-utils.js" if (Test-Path $OpsUtilsJs) { Copy-Item -Path $OpsUtilsJs -Destination $ScriptsDir -Force } # Security for Secrets $SecretsPath = Join-Path $OpsRoot "Secrets" try { $Acl = Get-Acl -Path $SecretsPath # Disable inheritance and drop the inherited rules $Acl.SetAccessRuleProtection($true, $false) # Well-known SIDs, not display names: "Administrators"/"SYSTEM" do not # resolve on non-English Windows installations. $AdminsSid = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid, $null) $SystemSid = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::LocalSystemSid, $null) $AdminRule = New-Object System.Security.AccessControl.FileSystemAccessRule($AdminsSid, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow") $SystemRule = New-Object System.Security.AccessControl.FileSystemAccessRule($SystemSid, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow") $Acl.AddAccessRule($AdminRule) $Acl.AddAccessRule($SystemRule) # Grant Service Account Read/Execute if provided if (-not [string]::IsNullOrWhiteSpace($ServiceAccount)) { $ServiceRule = New-Object System.Security.AccessControl.FileSystemAccessRule($ServiceAccount, "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow") $Acl.AddAccessRule($ServiceRule) } Set-Acl -Path $SecretsPath -AclObject $Acl Write-Host "Secured $SecretsPath" } catch { Write-Warning "Could not secure '$SecretsPath'. Secrets may be readable by unprivileged users. Error: $_" } # Register Maintenance Task (Cleanup Logs) $CleanupScript = Join-Path $OpsRoot "Bin\Cleanup-OpsLogs.ps1" $NewOpsJobScript = Join-Path $OpsRoot "Bin\New-OpsJob.ps1" if ((Test-Path -Path $CleanupScript) -and (Test-Path -Path $NewOpsJobScript)) { $Params = @{ JobName = "OpsMaintenance-LogCleanup" ScriptPath = $CleanupScript ScheduleTime = [datetime]::ParseExact("02:00", "HH:mm", [cultureinfo]::InvariantCulture) ScriptArguments = @{ DaysToKeep = 30 } } if (-not [string]::IsNullOrWhiteSpace($ServiceAccount)) { $Params["ServiceAccountUser"] = $ServiceAccount } try { & $NewOpsJobScript @Params Write-Host "Registered Maintenance Task: OpsMaintenance-LogCleanup" } catch { Write-Warning "Could not register the log cleanup maintenance task: $_" } } } # End of Initialize-OrchestratorEnvironment function Invoke-OpsScript { param ( [string]$ScriptName, [hashtable]$Arguments = @{} ) $ScriptPath = Join-Path (Join-Path $script:OpsRoot "Bin") $ScriptName if (-not (Test-Path $ScriptPath)) { throw "Script not found: $ScriptPath. Run Initialize-OrchestratorEnvironment first." } # $PSBoundParameters carries common parameters (-Verbose, -ErrorAction, ...) # that the plain param() blocks of the asset scripts cannot bind. $Splat = @{} foreach ($Key in $Arguments.Keys) { if ($script:CommonParameterNames -notcontains $Key) { $Splat[$Key] = $Arguments[$Key] } } & $ScriptPath @Splat } # Proxy Functions function New-OpsJob { [CmdletBinding()] param( [Parameter(Mandatory=$true)][string]$JobName, [Parameter(Mandatory=$true)][string]$ScriptPath, [Parameter(Mandatory=$true)][datetime]$ScheduleTime, [string]$ServiceAccountUser, [timespan]$Interval, # [object] so a hashtable of named parameters survives, as documented in # the README. [string[]] silently flattened it into useless strings. [object]$ScriptArguments, [string[]]$EmailRecipients, [string]$AlertWebhookUrl, [string[]]$RequiredSecrets ) Invoke-OpsScript -ScriptName "New-OpsJob.ps1" -Arguments $PSBoundParameters } function Register-OpsJobs { [CmdletBinding()] param([Parameter(Mandatory=$true)][string]$ConfigPath) Invoke-OpsScript -ScriptName "Register-OpsJobs.ps1" -Arguments $PSBoundParameters } function New-OpsCreds { [CmdletBinding()] param([Parameter(Mandatory=$true)][string]$Name) Invoke-OpsScript -ScriptName "New-OpsCreds.ps1" -Arguments $PSBoundParameters } function Get-OpsJob { [CmdletBinding()] param([string]$JobName) Invoke-OpsScript -ScriptName "Get-OpsJob.ps1" -Arguments $PSBoundParameters } function Start-OpsJob { [CmdletBinding()] param([Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)][string]$JobName) process { Invoke-OpsScript -ScriptName "Start-OpsJob.ps1" -Arguments @{ JobName = $JobName } } } function Stop-OpsJob { [CmdletBinding()] param([Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)][string]$JobName) process { Invoke-OpsScript -ScriptName "Stop-OpsJob.ps1" -Arguments @{ JobName = $JobName } } } function Remove-OpsJob { [CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='High')] param([Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)][string]$JobName) process { if ($PSCmdlet.ShouldProcess("OpsJob-$JobName", "Unregister scheduled task")) { Invoke-OpsScript -ScriptName "Remove-OpsJob.ps1" -Arguments @{ JobName = $JobName } } } } function Get-OpsJobHistory { [CmdletBinding()] param([string]$JobName, [int]$Count) Invoke-OpsScript -ScriptName "Get-OpsJobHistory.ps1" -Arguments $PSBoundParameters } function Get-OpsJobLog { [CmdletBinding()] param([string]$JobName, [int]$Count) Invoke-OpsScript -ScriptName "Get-OpsJobLog.ps1" -Arguments $PSBoundParameters } Export-ModuleMember -Function Initialize-OrchestratorEnvironment, New-OpsJob, Register-OpsJobs, New-OpsCreds, Get-OpsJob, Start-OpsJob, Stop-OpsJob, Remove-OpsJob, Get-OpsJobHistory, Get-OpsJobLog |