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