mirror of
https://github.com/MAKS-IT-COM/uscheduler.git
synced 2025-12-31 04:00:01 +01:00
409 lines
11 KiB
Markdown
409 lines
11 KiB
Markdown
|
||
|
||
# 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
|
||
|
||
- [MaksIT Unified Scheduler Service](#maksit-unified-scheduler-service)
|
||
- [Table of Contents](#table-of-contents)
|
||
- [Features at a Glance](#features-at-a-glance)
|
||
- [Installation](#installation)
|
||
- [Recommended (using bundled scripts)](#recommended-using-bundled-scripts)
|
||
- [Manual Installation](#manual-installation)
|
||
- [Configuration (`appsettings.json`)](#configuration-appsettingsjson)
|
||
- [PowerShell Scripts](#powershell-scripts)
|
||
- [Processes](#processes)
|
||
- [How It Works](#how-it-works)
|
||
- [PowerShell Execution Parameters](#powershell-execution-parameters)
|
||
- [Thread Layout](#thread-layout)
|
||
- [Reusable Scheduler Module (`SchedulerTemplate.psm1`)](#reusable-scheduler-module-schedulertemplatepsm1)
|
||
- [Example usage](#example-usage)
|
||
- [Security](#security)
|
||
- [Logging](#logging)
|
||
- [Contact](#contact)
|
||
- [License](#license)
|
||
- [Appendix](#appendix)
|
||
- [SchedulerTemplate.psm1 (Full Source)](#schedulertemplatepsm1-full-source)
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
### Recommended (using bundled scripts)
|
||
|
||
```bat
|
||
cd /d path\to\src\MaksIT.UScheduler
|
||
Install.cmd
|
||
```
|
||
|
||
To uninstall:
|
||
|
||
```bat
|
||
Uninstall.cmd
|
||
```
|
||
|
||
### Manual Installation
|
||
|
||
```powershell
|
||
sc.exe create "MaksIT.UScheduler Service" binpath="C:\Path\To\MaksIT.UScheduler.exe"
|
||
sc.exe start "MaksIT.UScheduler Service"
|
||
```
|
||
|
||
Manual uninstall:
|
||
|
||
```powershell
|
||
sc.exe delete "MaksIT.UScheduler Service"
|
||
```
|
||
|
||
---
|
||
|
||
## Configuration (`appsettings.json`)
|
||
|
||
```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
|
||
* `IsSigned` — `true` 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
|
||
|
||
```csharp
|
||
myCommand.Parameters.Add(new CommandParameter("Automated", true));
|
||
myCommand.Parameters.Add(new CommandParameter("CurrentDateTimeUtc", DateTime.UtcNow.ToString("o")));
|
||
```
|
||
|
||
Inside the script:
|
||
|
||
```powershell
|
||
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
|
||
|
||
```powershell
|
||
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`
|
||
|
||
That’s 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)
|
||
|
||
```powershell
|
||
# ======================================================================
|
||
# 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 *
|
||
```
|