uscheduler/README.md
2025-12-06 13:06:47 +01:00

11 KiB
Raw Blame History

MaksIT Unified Scheduler Service

A modern, fully rewritten Windows service built on .NET 10 for scheduling and running PowerShell scripts and console applications. Designed for system administrators — and also for those who feel like system administrators — who need a predictable, resilient, and secure background execution environment.


Table of Contents


Features at a Glance

  • .NET 10 Worker Service clean, robust, stable.
  • Strongly typed configuration via appsettings.json.
  • Run PowerShell scripts & executables concurrently (each in its own thread).
  • Signature enforcement (AllSigned by default).
  • Automatic restart-on-failure for supervised processes.
  • Extensible logging (file + console).
  • Simple Install.cmd / Uninstall.cmd.
  • Reusable scheduling module: SchedulerTemplate.psm1.
  • Thread-isolated architecture — individual failures do not affect others.

Installation

cd /d path\to\src\MaksIT.UScheduler
Install.cmd

To uninstall:

Uninstall.cmd

Manual Installation

sc.exe create "MaksIT.UScheduler Service" binpath="C:\Path\To\MaksIT.UScheduler.exe"
sc.exe start "MaksIT.UScheduler Service"

Manual uninstall:

sc.exe delete "MaksIT.UScheduler Service"

Configuration (appsettings.json)

{
  "Configuration": {
    "ServiceName": "MaksIT.UScheduler",
    "LogDir": "C:\\Logs",

    "Powershell": [
      { "Path": "C:\\Scripts\\MyScript.ps1", "IsSigned": true }
    ],

    "Processes": [
      { "Path": "C:\\Programs\\MyApp.exe", "Args": ["--option"], "RestartOnFailure": true }
    ]
  }
}

PowerShell Scripts

  • Path — full .ps1 file path
  • IsSignedtrue enforces AllSigned, false runs unrestricted

Processes

  • Path — executable
  • Args — command-line arguments
  • RestartOnFailure — restart logic handled by service

How It Works

Each script or process is executed in its own managed thread.

PowerShell Execution Parameters

myCommand.Parameters.Add(new CommandParameter("Automated", true));
myCommand.Parameters.Add(new CommandParameter("CurrentDateTimeUtc", DateTime.UtcNow.ToString("o")));

Inside the script:

param (
    [switch]$Automated,
    [string]$CurrentDateTimeUtc
)

Thread Layout

Unified Scheduler Service
├── PowerShell
│   ├── ScriptA.ps1     Thread
│   ├── ScriptB.ps1     Thread
│   └── ...
└── Processes
    ├── ProgramA.exe     Thread
    ├── ProgramB.exe     Thread
    └── ...

A crash in one thread never stops the service or other components.


Reusable Scheduler Module (SchedulerTemplate.psm1)

This module provides:

  • Scheduling by:

    • Month
    • Weekday
    • Exact time(s)
    • Minimum interval
  • Automatic lock file (no concurrent execution)

  • Last-run file tracking

  • Unified callback execution pattern

  • Logging helpers (Write-Log)

Example usage

param (
    [switch]$Automated,
    [string]$CurrentDateTimeUtc
)

Import-Module "$PSScriptRoot\..\SchedulerTemplate.psm1" -Force

$Config = @{
    RunMonth = @()
    RunWeekday = @()
    RunTime = @("22:52")
    MinIntervalMinutes = 10
}

function Start-BusinessLogic {
     Write-Log "Executing business logic..." -Automated:$Automated
}

Invoke-ScheduledExecution -Config $Config -Automated:$Automated -CurrentDateTimeUtc $CurrentDateTimeUtc -ScriptBlock {
    Start-BusinessLogic
}

Workflow for new scheduled scripts:

  1. Copy template
  2. Modify $Config
  3. Implement Start-BusinessLogic
  4. Add script to appsettings.json

Thats it — the full scheduling engine is reused automatically.


Security

  • Signed scripts required by default.
  • Scripts are auto-unblocked before execution.
  • Unrestricted execution can be enabled if needed (not recommended on production systems).

Logging

  • Console logging
  • File logging under the directory specified by LogDir
  • All events (start, stop, crash, restart, error, skip) are logged

Contact

Maksym Sadovnychyy MAKS-IT, 2025 Email: maksym.sadovnychyy@gmail.com


License

MIT License Copyright (c) 2025 Maksym Sadovnychyy MAKS-IT maksym.sadovnychyy@gmail.com


Appendix

SchedulerTemplate.psm1 (Full Source)

# ======================================================================
# SchedulerTemplate.psm1 - Scheduling + Lock + Interval + Callback Runner
# ======================================================================

function Write-Log {
    param(
        [string]$Message,
        [switch]$Automated,
        [string]$Color = 'White'
    )

    if ($Automated) {
        Write-Output $Message
    }
    else {
        Write-Host $Message -ForegroundColor $Color
    }
}

function Get-CurrentUtcDateTime {
    param([string]$ExternalDateTime, [switch]$Automated)

    if ($ExternalDateTime) {
        try {
            return [datetime]::Parse($ExternalDateTime).ToUniversalTime()
        }
        catch {
            try {
                return [datetime]::ParseExact($ExternalDateTime, 'dd/MM/yyyy HH:mm:ss', $null).ToUniversalTime()
            }
            catch {
                Write-Log "Failed to parse CurrentDateTimeUtc ('$ExternalDateTime'). Using system time (UTC)." -Automated:$Automated -Color 'Red'
                return (Get-Date).ToUniversalTime()
            }
        }
    }
    return (Get-Date).ToUniversalTime()
}

function Test-ScheduleMonth { param([datetime]$DateTime, [array]$Months)
    $name = $DateTime.ToString('MMMM')
    return ($Months.Count -eq 0) -or ($Months -contains $name)
}
function Test-ScheduleWeekday { param([datetime]$DateTime, [array]$Weekdays)
    $name = $DateTime.DayOfWeek.ToString()
    return ($Weekdays.Count -eq 0) -or ($Weekdays -contains $name)
}
function Test-ScheduleTime { param([datetime]$DateTime, [array]$Times)
    $t = $DateTime.ToString('HH:mm')
    return ($Times.Count -eq 0) -or ($Times -contains $t)
}

function Test-Schedule {
    param(
        [datetime]$DateTime,
        [array]$RunMonth,
        [array]$RunWeekday,
        [array]$RunTime
    )

    return (Test-ScheduleMonth -DateTime $DateTime -Months $RunMonth) -and
           (Test-ScheduleWeekday -DateTime $DateTime -Weekdays $RunWeekday) -and
           (Test-ScheduleTime -DateTime $DateTime -Times $RunTime)
}

function Test-Interval {
    param([datetime]$LastRun,[datetime]$Now,[int]$MinIntervalMinutes)
    return $Now -ge $LastRun.AddMinutes($MinIntervalMinutes)
}

function Test-ScheduledExecution {
    param(
        [switch]$Automated,
        [string]$CurrentDateTimeUtc,
        [hashtable]$Config,
        [string]$LastRunFilePath
    )

    $now = Get-CurrentUtcDateTime -ExternalDateTime $CurrentDateTimeUtc -Automated:$Automated
    $shouldRun = $true

    if ($Automated) {
        Write-Log "Automated: $Automated" -Automated:$Automated -Color 'Green'
        Write-Log "Current UTC Time: $now" -Automated:$Automated -Color 'Green'

        if (-not (Test-Schedule -DateTime $now -RunMonth $Config.RunMonth -RunWeekday $Config.RunWeekday -RunTime $Config.RunTime)) {
            Write-Log "Execution skipped due to schedule." -Automated:$Automated -Color 'Yellow'
            $shouldRun = $false
        }
    }

    if ($shouldRun -and $LastRunFilePath -and (Test-Path $LastRunFilePath)) {
        $lastRun = Get-Content $LastRunFilePath | Select-Object -First 1
        if ($lastRun) {
            [datetime]$lr = $lastRun
            if (-not (Test-Interval -LastRun $lr -Now $now -MinIntervalMinutes $Config.MinIntervalMinutes)) {
                Write-Log "Last run at $lr. Interval not reached." -Automated:$Automated -Color 'Yellow'
                $shouldRun = $false
            }
        }
    }

    return @{
        ShouldExecute = $shouldRun
        Now = $now
    }
}

function New-LockGuard {
    param([string]$LockFile,[switch]$Automated)

    if (Test-Path $LockFile) {
        Write-Log "Guard: Lock file exists ($LockFile). Skipping." -Automated:$Automated -Color 'Red'
        return $false
    }
    try {
        New-Item -Path $LockFile -ItemType File -Force | Out-Null
        return $true
    }
    catch {
        Write-Log "Guard: Cannot create lock file ($LockFile)." -Automated:$Automated -Color 'Red'
        return $false
    }
}

function Remove-LockGuard {
    param([string]$LockFile,[switch]$Automated)
    if (Test-Path $LockFile) {
        Remove-Item $LockFile -Force
        Write-Log "Lock removed: $LockFile" -Automated:$Automated -Color 'Cyan'
    }
}

# ======================================================================
# Main unified executor (callback-based)
# ======================================================================
function Invoke-ScheduledExecution {
    param(
        [scriptblock]$ScriptBlock,
        [hashtable]$Config,
        [switch]$Automated,
        [string]$CurrentDateTimeUtc
    )

    $scriptPath  = $MyInvocation.ScriptName
    $lastRunFile = [IO.Path]::ChangeExtension($scriptPath, ".lastRun")
    $lockFile    = [IO.Path]::ChangeExtension($scriptPath, ".lock")

    # Check schedule
    $schedule = Test-ScheduledExecution -Automated:$Automated -CurrentDateTimeUtc $CurrentDateTimeUtc -Config $Config -LastRunFilePath $lastRunFile
    if (-not $schedule.ShouldExecute) {
        Write-Log "Execution skipped." -Automated:$Automated -Color 'Yellow'
        return
    }

    # Lock
    if (-not (New-LockGuard -LockFile $lockFile -Automated:$Automated)) {
        return
    }

    try {
        $schedule.Now.ToString("o") | Set-Content $lastRunFile
        & $ScriptBlock
    }
    finally {
        Remove-LockGuard -LockFile $lockFile -Automated:$Automated
    }
}

Export-ModuleMember -Function * -Alias *