mirror of
https://github.com/MAKS-IT-COM/uscheduler.git
synced 2025-12-30 19:50:01 +01:00
(feature): migrate to .net10
This commit is contained in:
parent
635423b083
commit
aa5b446d8b
23
CHANGELOG.md
Normal file
23
CHANGELOG.md
Normal file
@ -0,0 +1,23 @@
|
||||
# MaksIT.UScheduler Changelog
|
||||
|
||||
## v1.0.0 - 2025-12-06
|
||||
|
||||
### Major Changes
|
||||
- Migrate of the Unified Scheduler Service in .NET 10 (previously .NET 8).
|
||||
- New solution and project structure under `MaksIT.UScheduler`.
|
||||
- Added support for scheduling and running both PowerShell scripts and console applications as Windows services.
|
||||
- Strongly typed configuration via `appsettings.json` and `Configuration.cs`.
|
||||
- Improved logging with configurable log directory.
|
||||
- New background services:
|
||||
- `PSScriptBackgroundService` for PowerShell script execution.
|
||||
- `ProcessBackgroundService` for process management.
|
||||
- Enhanced PowerShell script execution with signature validation and script unblocking.
|
||||
- Improved process management with restart-on-failure logic.
|
||||
- Updated install/uninstall scripts (`Install.cmd`, `Uninstall.cmd`) for service management.
|
||||
- Added comprehensive README with usage, configuration, and scheduling examples.
|
||||
- MIT License included.
|
||||
|
||||
### Breaking Changes
|
||||
- Old solution, project, and service files removed.
|
||||
- Configuration format and service naming conventions updated.
|
||||
- Scheduling logic for console applications is not yet implemented (runs every 10 seconds).
|
||||
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) [year] [fullname]
|
||||
Copyright (c) 2025 Maksym Sadovnychyy (Maks-IT)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
483
README.md
483
README.md
@ -1,153 +1,408 @@
|
||||
# Unified Scheduler Service
|
||||
|
||||
Is'a completelly rewritten in .NET8 version of **PowerShell Scrip Service** realized in .Net Framework 4.8
|
||||
|
||||
As previously, this project still has an aim to allow **System Administrators** and also to who **Thinks to be System Administrator** to launch **Power Shell** scripts and **Console Programs** as **Windows Service**.
|
||||
# MaksIT Unified Scheduler Service
|
||||
|
||||
## Latest builds
|
||||
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.
|
||||
|
||||
## How to Install and Uninstall Service
|
||||
---
|
||||
|
||||
### Service Install
|
||||
## Table of Contents
|
||||
|
||||
```powershell
|
||||
sc.exe create "Unified Scheduler Service" binpath="C:\Path\To\UScheduler.exe"
|
||||
```
|
||||
- [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)
|
||||
|
||||
with providing custom `contentRoot`:
|
||||
---
|
||||
|
||||
```powershell
|
||||
sc.exe create "Unified Scheduler Service" binpath="C:\Path\To\UScheduler.exe --contentRoot C:\Other\Path"
|
||||
```
|
||||
## Features at a Glance
|
||||
|
||||
Edit `appsettings.json`` according your needs. Differently from previuos version it doesn't scans a folders for scripts and same for programs, but you have explicitly set what should be launched. Also, when changes are made, you have to restart service. This will improve security of your environment.
|
||||
* **.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.
|
||||
|
||||
Then **start** your **Unified Scheduler Service**
|
||||
---
|
||||
|
||||
I have also prepared ***.cmd** file to simplify service system integration:
|
||||
## Installation
|
||||
|
||||
### Recommended (using bundled scripts)
|
||||
|
||||
```bat
|
||||
cd /d path\to\src\MaksIT.UScheduler
|
||||
Install.cmd
|
||||
|
||||
```bat
|
||||
sc.exe create "Unified Scheduler Service" binpath="%~dp0UScheduler.exe"
|
||||
pause
|
||||
```
|
||||
|
||||
>These ***.cmd** files have to be launched with **Admin** privileges.
|
||||
To uninstall:
|
||||
|
||||
After installation you have to start your newly created windows service: Win+R -> services.msc -> Enter -> Search by DisplayName.
|
||||
```bat
|
||||
Uninstall.cmd
|
||||
```
|
||||
|
||||
### Service Uninstall
|
||||
### Manual Installation
|
||||
|
||||
```powershell
|
||||
sc.exe "Unified Scheduler Service"
|
||||
sc.exe create "MaksIT.UScheduler Service" binpath="C:\Path\To\MaksIT.UScheduler.exe"
|
||||
sc.exe start "MaksIT.UScheduler Service"
|
||||
```
|
||||
|
||||
Uninstall.cmd
|
||||
Manual uninstall:
|
||||
|
||||
```bat
|
||||
sc.exe "Unified Scheduler Service"
|
||||
pause
|
||||
```powershell
|
||||
sc.exe delete "MaksIT.UScheduler Service"
|
||||
```
|
||||
|
||||
## How it works
|
||||
---
|
||||
|
||||
Here is a short explanation of two functional parts currently available.
|
||||
|
||||
### Processes
|
||||
|
||||
> Warning: For the moment I haven't realized any scheduling functionality for `console applications`, so be carefull, if your program is not a service kind, like `node derver`, `syncthing` ecc... it will execute it continuously every 10 senconds after completes.
|
||||
|
||||
This functionality is aimed to execute `console app services` which do not provide any windows service integration, and keeps it always alive.
|
||||
|
||||
### Powershell
|
||||
|
||||
Executes scripts whith following command parameters every 10 seconds:
|
||||
|
||||
```C#
|
||||
myCommand.Parameters.Add(new CommandParameter("Automated", true));
|
||||
myCommand.Parameters.Add(new CommandParameter("CurrentDateTimeUtc", DateTime.UtcNow.ToString("o")));
|
||||
```
|
||||
|
||||
Retrieve parameters this way:
|
||||
|
||||
```PowerShell
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[switch]$Automated,
|
||||
[string]$CurrentDateTime
|
||||
)
|
||||
|
||||
if($CurrentDateTime) {
|
||||
[datetime]$CurrentDateTime = [datetime]::parseexact($CurrentDateTime, 'dd/MM/yyyy HH:mm:ss', $null)
|
||||
}
|
||||
|
||||
Write-Host "Automated: $Automated" -ForegroundColor Green
|
||||
Write-Host "CurrentDateTime: $CurrentDateTime" -ForegroundColor Green
|
||||
```
|
||||
|
||||
Thanks to that, it's possible to create standalone scripts or automated scheduled scripts, which will be executed according to the script managed schedule logic.
|
||||
|
||||
### Thread organization
|
||||
|
||||
Every script and program is launched in its **own thread**, so if one crashes, others are able to continue:
|
||||
|
||||
```
|
||||
Unified Scheduler Service Thread
|
||||
├── Powershell
|
||||
│ ├── /Scripts/SomeStuff_1/StartScript.ps1 Thread
|
||||
│ ├── /Scripts/SomeStuff_2/StartScript.ps1 Thread
|
||||
│ └── ...
|
||||
└── Processes
|
||||
├── /Programs/SomeStuff_1/Program.exe
|
||||
├── /Programs/SomeStuff_2/Program.exe
|
||||
└── ...
|
||||
```
|
||||
|
||||
> By default It's set to execute only **signed** scrips, but if you don't care about your environment security, it's possible to launch them in **unrestricted** mode.
|
||||
>
|
||||
> Continue to read to see other possible settings...
|
||||
|
||||
## Configurations
|
||||
|
||||
Here are all currently available configurations inside `appsettings.json`:
|
||||
## Configuration (`appsettings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
//...
|
||||
|
||||
"Configurations": {
|
||||
"ServiceName": "UScheduler",
|
||||
"Description": "Windows service, which allows you to invoke PowerShell Scripts and Processes",
|
||||
"DisplayName": "Unified Scheduler Service",
|
||||
"Configuration": {
|
||||
"ServiceName": "MaksIT.UScheduler",
|
||||
"LogDir": "C:\\Logs",
|
||||
|
||||
"Powershell": [
|
||||
{
|
||||
"Path": "C:\\UScheduler\\Scripts\\Demo\\StartScript.ps1",
|
||||
"Signed": true
|
||||
}
|
||||
{ "Path": "C:\\Scripts\\MyScript.ps1", "IsSigned": true }
|
||||
],
|
||||
|
||||
"Processes": [
|
||||
{
|
||||
"Path": "C:\\UScheduler\\Programs\\syncthing-windows-amd64-v1.27.1\\syncthing.exe",
|
||||
"Args": [],
|
||||
"RestartOnFailure": true
|
||||
}
|
||||
{ "Path": "C:\\Programs\\MyApp.exe", "Args": ["--option"], "RestartOnFailure": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Let's see each one:
|
||||
### PowerShell Scripts
|
||||
|
||||
* ServiceName - System service name. I suggest to use short names without spaces or other strange characters. See [What are valid characters in a Windows service (key) name?](https://stackoverflow.com/questions/801280/what-are-valid-characters-in-a-windows-service-key-name).
|
||||
* Description - Description you wants to give to this service. Just put something very serious and technically complex to admire what kind of DUDE you are!
|
||||
* DisplayName - Same thing like for ServiceName, but you are free to use spaces.
|
||||
* Powershell:
|
||||
* ScriptsPath - Specify script to launch.
|
||||
* SignedScripts - **true** for **AllSigned** or **false** for **Unrestricted**.
|
||||
* Processes:
|
||||
* Path - Specify program to launch.
|
||||
* Args - Program command line arguments
|
||||
* RestartOnFailure - Allows to restart if something went wrong with program.
|
||||
* `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 *
|
||||
```
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.8.34330.188
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.0.11222.15 d18.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UScheduler", "UScheduler\UScheduler.csproj", "{DE1F347C-D201-42E2-8D22-924508FD30AA}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler", "MaksIT.UScheduler\MaksIT.UScheduler.csproj", "{DE1F347C-D201-42E2-8D22-924508FD30AA}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@ -0,0 +1,74 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using MaksIT.UScheduler.Services;
|
||||
|
||||
|
||||
namespace MaksIT.UScheduler.BackgroundServices;
|
||||
|
||||
public sealed class PSScriptBackgroundService : BackgroundService {
|
||||
|
||||
private readonly ILogger<PSScriptBackgroundService> _logger;
|
||||
private readonly Configuration _configuration;
|
||||
private readonly PSScriptService _psScriptService;
|
||||
|
||||
public PSScriptBackgroundService(
|
||||
ILogger<PSScriptBackgroundService> logger,
|
||||
IOptions<Configuration> options,
|
||||
PSScriptService psScriptService
|
||||
) {
|
||||
_logger = logger;
|
||||
_configuration = options.Value;
|
||||
_psScriptService = psScriptService;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
_logger.LogInformation("Starting PSScriptBackgroundService");
|
||||
|
||||
try {
|
||||
var psScripts = _configuration.Powershell;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested) {
|
||||
_logger.LogInformation("Checking for PowerShell scripts to run");
|
||||
|
||||
foreach (var psScript in psScripts) {
|
||||
var scriptPath = psScript.Path;
|
||||
|
||||
if (scriptPath == string.Empty)
|
||||
continue;
|
||||
|
||||
_logger.LogInformation($"Running PowerShell script {scriptPath}");
|
||||
_psScriptService.RunScript(scriptPath, psScript.IsSigned, stoppingToken);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
// When the stopping token is canceled, for example, a call made from services.msc,
|
||||
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
||||
_logger.LogInformation("Stopping PSScriptBackgroundService due to cancellation request");
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, "{Message}", ex.Message);
|
||||
|
||||
// Terminates this process and returns an exit code to the operating system.
|
||||
// This is required to avoid the 'BackgroundServiceExceptionBehavior', which
|
||||
// performs one of two scenarios:
|
||||
// 1. When set to "Ignore": will do nothing at all, errors cause zombie services.
|
||||
// 2. When set to "StopHost": will cleanly stop the host, and log errors.
|
||||
//
|
||||
// In order for the Windows Service Management system to leverage configured
|
||||
// recovery options, we need to terminate the process with a non-zero exit code.
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public override Task StopAsync(CancellationToken stoppingToken) {
|
||||
// Perform cleanup tasks here
|
||||
_logger.LogInformation("Stopping PSScriptBackgroundService");
|
||||
|
||||
_logger.LogInformation("PSScriptBackgroundService stopped");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using UScheduler.Services;
|
||||
using MaksIT.UScheduler.Services;
|
||||
|
||||
namespace UScheduler.BackgroundServices;
|
||||
|
||||
namespace MaksIT.UScheduler.BackgroundServices;
|
||||
|
||||
public sealed class ProcessBackgroundService : BackgroundService {
|
||||
|
||||
@ -24,29 +24,21 @@ public sealed class ProcessBackgroundService : BackgroundService {
|
||||
_logger.LogInformation("Starting ProcessBackgroundService");
|
||||
|
||||
try {
|
||||
var processes = _configuration.ProcessesOrDefault;
|
||||
var processes = _configuration.Processes;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested) {
|
||||
_logger.LogInformation("Checking for processes to run");
|
||||
|
||||
//stop background service if there are no processes to run
|
||||
if (processes.Count == 0) {
|
||||
_logger.LogWarning("No processes to run, stopping ProcessBackgroundService");
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var process in processes) {
|
||||
var processPath = process.GetPathOrDefault;
|
||||
var processArgs = process.GetArgsOrDefault;
|
||||
var processPath = process.Path;
|
||||
var processArgs = process.Args;
|
||||
|
||||
if (processPath == string.Empty)
|
||||
continue;
|
||||
|
||||
_logger.LogInformation($"Running process {processPath} with arguments {string.Join(", ", processArgs)}");
|
||||
_processService.RunProcess(processPath, processArgs, stoppingToken);
|
||||
|
||||
}
|
||||
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
}
|
||||
@ -55,13 +47,10 @@ public sealed class ProcessBackgroundService : BackgroundService {
|
||||
// When the stopping token is canceled, for example, a call made from services.msc,
|
||||
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
||||
_logger.LogInformation("Stopping ProcessBackgroundService due to cancellation request");
|
||||
_processService.TerminateAllProcesses();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, "{Message}", ex.Message);
|
||||
|
||||
_processService.TerminateAllProcesses();
|
||||
|
||||
// Terminates this process and returns an exit code to the operating system.
|
||||
// This is required to avoid the 'BackgroundServiceExceptionBehavior', which
|
||||
// performs one of two scenarios:
|
||||
@ -78,8 +67,6 @@ public sealed class ProcessBackgroundService : BackgroundService {
|
||||
// Perform cleanup tasks here
|
||||
_logger.LogInformation("Stopping ProcessBackgroundService");
|
||||
|
||||
_processService.TerminateAllProcesses();
|
||||
|
||||
_logger.LogInformation("All processes terminated");
|
||||
|
||||
return Task.CompletedTask;
|
||||
23
src/MaksIT.UScheduler/CHANGELOG.md
Normal file
23
src/MaksIT.UScheduler/CHANGELOG.md
Normal file
@ -0,0 +1,23 @@
|
||||
# MaksIT.UScheduler Changelog
|
||||
|
||||
## v1.0.0 - 2025-12-06
|
||||
|
||||
### Major Changes
|
||||
- Migrate of the Unified Scheduler Service in .NET 10 (previously .NET 8).
|
||||
- New solution and project structure under `MaksIT.UScheduler`.
|
||||
- Added support for scheduling and running both PowerShell scripts and console applications as Windows services.
|
||||
- Strongly typed configuration via `appsettings.json` and `Configuration.cs`.
|
||||
- Improved logging with configurable log directory.
|
||||
- New background services:
|
||||
- `PSScriptBackgroundService` for PowerShell script execution.
|
||||
- `ProcessBackgroundService` for process management.
|
||||
- Enhanced PowerShell script execution with signature validation and script unblocking.
|
||||
- Improved process management with restart-on-failure logic.
|
||||
- Updated install/uninstall scripts (`Install.cmd`, `Uninstall.cmd`) for service management.
|
||||
- Added comprehensive README with usage, configuration, and scheduling examples.
|
||||
- MIT License included.
|
||||
|
||||
### Breaking Changes
|
||||
- Old solution, project, and service files removed.
|
||||
- Configuration format and service naming conventions updated.
|
||||
- Scheduling logic for console applications is not yet implemented (runs every 10 seconds).
|
||||
20
src/MaksIT.UScheduler/Configuration.cs
Normal file
20
src/MaksIT.UScheduler/Configuration.cs
Normal file
@ -0,0 +1,20 @@
|
||||
namespace MaksIT.UScheduler;
|
||||
|
||||
public class PowershellScript {
|
||||
public required string Path { get; set; }
|
||||
public bool IsSigned { get; set; } = false;
|
||||
}
|
||||
|
||||
public class ProcessConfiguration {
|
||||
public required string Path { get; set; }
|
||||
public string[]? Args { get; set; }
|
||||
public bool RestartOnFailure { get; set; } = false;
|
||||
}
|
||||
|
||||
public class Configuration {
|
||||
public string ServiceName { get; set; } = "MaksIT.UScheduler";
|
||||
public string? LogDir { get; set; }
|
||||
public List<PowershellScript> Powershell { get; set; } = [];
|
||||
public List<ProcessConfiguration> Processes { get; set; } = [];
|
||||
|
||||
}
|
||||
3
src/MaksIT.UScheduler/Install.cmd
Normal file
3
src/MaksIT.UScheduler/Install.cmd
Normal file
@ -0,0 +1,3 @@
|
||||
sc.exe create "MaksIT.UScheduler Service" binpath="%~dp0MaksIT.UScheduler.exe"
|
||||
sc description "MaksIT.UScheduler Service" "Windows service, which allows you to schedule and invoke PowerShell Scripts and Processes"
|
||||
pause
|
||||
21
src/MaksIT.UScheduler/LICENSE.md
Normal file
21
src/MaksIT.UScheduler/LICENSE.md
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Maksym Sadovnychyy (Maks-IT)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@ -1,38 +1,49 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>1.0.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>
|
||||
<OutputType>exe</OutputType>
|
||||
<PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
|
||||
<PublishSingleFile Condition="'$(Configuration)' == 'Release'">false</PublishSingleFile>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.PowerShell.Commands.Diagnostics" Version="7.4.0" />
|
||||
<PackageReference Include="Microsoft.PowerShell.Commands.Management" Version="7.4.0" />
|
||||
<PackageReference Include="Microsoft.PowerShell.Commands.Utility" Version="7.4.0" />
|
||||
<PackageReference Include="Microsoft.PowerShell.ConsoleHost" Version="7.4.0" />
|
||||
<PackageReference Include="Microsoft.WSMan.Management" Version="7.4.0" />
|
||||
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="8.0.0" />
|
||||
<PackageReference Include="System.Management.Automation" Version="7.4.0" />
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.PowerShell.Commands.Diagnostics" Version="7.5.4" />
|
||||
<PackageReference Include="Microsoft.PowerShell.Commands.Management" Version="7.5.4" />
|
||||
<PackageReference Include="Microsoft.PowerShell.Commands.Utility" Version="7.5.4" />
|
||||
<PackageReference Include="Microsoft.PowerShell.ConsoleHost" Version="7.5.3" />
|
||||
<PackageReference Include="Microsoft.WSMan.Management" Version="7.5.4" />
|
||||
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="10.0.0" />
|
||||
<PackageReference Include="System.Management.Automation" Version="7.5.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Properties\PublishProfiles\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="CHANGELOG.md">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Install.cmd">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="LICENSE.md">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="README.md">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Uninstall.cmd">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Properties\PublishProfiles\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
24
src/MaksIT.UScheduler/MaksIT.UScheduler.sln
Normal file
24
src/MaksIT.UScheduler/MaksIT.UScheduler.sln
Normal file
@ -0,0 +1,24 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaksIT.UScheduler", "MaksIT.UScheduler.csproj", "{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E81A303A-732F-0CE9-2A4C-4C26971DB9D1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {F6EE17FC-4D3A-420D-B534-433875D783B3}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
57
src/MaksIT.UScheduler/Program.cs
Normal file
57
src/MaksIT.UScheduler/Program.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using MaksIT.Core.Logging;
|
||||
using MaksIT.UScheduler;
|
||||
using MaksIT.UScheduler.BackgroundServices;
|
||||
using MaksIT.UScheduler.Services;
|
||||
using Microsoft.Extensions.Logging.Configuration;
|
||||
using Microsoft.Extensions.Logging.EventLog;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
|
||||
// read configuration from appsettings.json
|
||||
var configurationRoot = new ConfigurationBuilder()
|
||||
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.Build();
|
||||
|
||||
// Configure strongly typed settings objects
|
||||
var configurationSection = configurationRoot.GetSection("Configuration");
|
||||
var appSettings = configurationSection.Get<Configuration>() ?? throw new ArgumentNullException();
|
||||
|
||||
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Services.AddWindowsService(options => {
|
||||
options.ServiceName = appSettings.ServiceName;
|
||||
});
|
||||
|
||||
// Allow configurations to be available through IOptions<Configuration>
|
||||
builder.Services.Configure<Configuration>(configurationSection);
|
||||
|
||||
// Logging
|
||||
var logPath = !string.IsNullOrEmpty(appSettings.LogDir)
|
||||
? Path.Combine(appSettings.LogDir)
|
||||
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
|
||||
|
||||
if (!Directory.Exists(logPath)) {
|
||||
Directory.CreateDirectory(logPath);
|
||||
}
|
||||
|
||||
builder.Logging.AddConsoleLogger(logPath);
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||
LoggerProviderOptions.RegisterProviderOptions<
|
||||
EventLogSettings, EventLogLoggerProvider>(builder.Services);
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton<ProcessService>();
|
||||
builder.Services.AddHostedService<ProcessBackgroundService>();
|
||||
|
||||
builder.Services.AddSingleton<PSScriptService>();
|
||||
builder.Services.AddHostedService<PSScriptBackgroundService>();
|
||||
|
||||
IHost host = builder.Build();
|
||||
|
||||
// Test logger
|
||||
var loggerFactory = builder.Logging.Services.BuildServiceProvider().GetRequiredService<ILoggerFactory>();
|
||||
var testLogger = loggerFactory.CreateLogger("LoggerTest");
|
||||
testLogger.LogInformation("Logger test: This should appear in your log file.");
|
||||
|
||||
host.Run();
|
||||
357
src/MaksIT.UScheduler/README.md
Normal file
357
src/MaksIT.UScheduler/README.md
Normal file
@ -0,0 +1,357 @@
|
||||
# Unified Scheduler Service
|
||||
|
||||
Is'a completelly rewritten in .NET8 version of **PowerShell Scrip Service** realized in .Net Framework 4.8
|
||||
|
||||
As previously, this project still has an aim to allow **System Administrators** and also to who **Thinks to be System Administrator** to launch **Power Shell** scripts and **Console Programs** as **Windows Service**.
|
||||
|
||||
## Latest builds
|
||||
|
||||
## How to Install and Uninstall Service
|
||||
|
||||
### Service Install
|
||||
|
||||
```powershell
|
||||
sc.exe create "Unified Scheduler Service" binpath="C:\Path\To\UScheduler.exe"
|
||||
```
|
||||
|
||||
with providing custom `contentRoot`:
|
||||
|
||||
```powershell
|
||||
sc.exe create "Unified Scheduler Service" binpath="C:\Path\To\UScheduler.exe --contentRoot C:\Other\Path"
|
||||
```
|
||||
|
||||
Edit `appsettings.json`` according your needs. Differently from previuos version it doesn't scans a folders for scripts and same for programs, but you have explicitly set what should be launched. Also, when changes are made, you have to restart service. This will improve security of your environment.
|
||||
|
||||
Then **start** your **Unified Scheduler Service**
|
||||
|
||||
I have also prepared ***.cmd** file to simplify service system integration:
|
||||
|
||||
Install.cmd
|
||||
|
||||
```bat
|
||||
sc.exe create "Unified Scheduler Service" binpath="%~dp0UScheduler.exe"
|
||||
pause
|
||||
```
|
||||
|
||||
>These ***.cmd** files have to be launched with **Admin** privileges.
|
||||
|
||||
After installation you have to start your newly created windows service: Win+R -> services.msc -> Enter -> Search by DisplayName.
|
||||
|
||||
### Service Uninstall
|
||||
|
||||
```powershell
|
||||
sc.exe "Unified Scheduler Service"
|
||||
```
|
||||
|
||||
Uninstall.cmd
|
||||
|
||||
```bat
|
||||
sc.exe "Unified Scheduler Service"
|
||||
pause
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
Here is a short explanation of two functional parts currently available.
|
||||
|
||||
### Processes
|
||||
|
||||
> Warning: For the moment I haven't realized any scheduling functionality for `console applications`, so be carefull, if your program is not a service kind, like `node derver`, `syncthing` ecc... it will execute it continuously every 10 senconds after completes.
|
||||
|
||||
This functionality is aimed to execute `console app services` which do not provide any windows service integration, and keeps it always alive.
|
||||
|
||||
### Powershell
|
||||
|
||||
Executes scripts whith following command parameters every 10 seconds:
|
||||
|
||||
```C#
|
||||
myCommand.Parameters.Add(new CommandParameter("Automated", true));
|
||||
myCommand.Parameters.Add(new CommandParameter("CurrentDateTimeUtc", DateTime.UtcNow.ToString("o")));
|
||||
```
|
||||
|
||||
Retrieve parameters this way:
|
||||
|
||||
```PowerShell
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[switch]$Automated,
|
||||
[string]$CurrentDateTimeUtc
|
||||
)
|
||||
|
||||
# ======================================================================
|
||||
# CONFIGURATION BLOCK (only modify values here)
|
||||
# ======================================================================
|
||||
$Config = @{
|
||||
RunMonth = @() # e.g. @("January","December")
|
||||
RunWeekday = @() # e.g. @("Monday","Friday")
|
||||
RunTime = @("20:28") # UTC times HH:mm
|
||||
MinIntervalMinutes = 10
|
||||
}
|
||||
# ======================================================================
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# LOGGING
|
||||
# ======================================================================
|
||||
function Write-Log {
|
||||
param(
|
||||
[string]$Message,
|
||||
[string]$Color = 'White'
|
||||
)
|
||||
|
||||
if ($Automated) {
|
||||
Write-Output $Message
|
||||
}
|
||||
else {
|
||||
Write-Host $Message -ForegroundColor $Color
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# TIME PARSING AND SCHEDULING HELPERS
|
||||
# ======================================================================
|
||||
function Get-CurrentUtcDateTime {
|
||||
param([string]$ExternalDateTime)
|
||||
|
||||
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) instead." 'Red'
|
||||
return (Get-Date).ToUniversalTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
return (Get-Date).ToUniversalTime()
|
||||
}
|
||||
}
|
||||
|
||||
function Test-ScheduleMonth {
|
||||
param([datetime]$DateTime, [array]$Months)
|
||||
$monthName = $DateTime.ToString('MMMM')
|
||||
return ($Months.Count -eq 0) -or ($Months -contains $monthName)
|
||||
}
|
||||
|
||||
function Test-ScheduleWeekday {
|
||||
param([datetime]$DateTime, [array]$Weekdays)
|
||||
$weekdayName = $DateTime.DayOfWeek.ToString()
|
||||
return ($Weekdays.Count -eq 0) -or ($Weekdays -contains $weekdayName)
|
||||
}
|
||||
|
||||
function Test-ScheduleTime {
|
||||
param([datetime]$DateTime, [array]$Times)
|
||||
$timeString = $DateTime.ToString('HH:mm')
|
||||
return ($Times.Count -eq 0) -or ($Times -contains $timeString)
|
||||
}
|
||||
|
||||
function Test-Schedule {
|
||||
param(
|
||||
[datetime]$DateTime,
|
||||
[array]$Months,
|
||||
[array]$Weekdays,
|
||||
[array]$Times
|
||||
)
|
||||
return (Test-ScheduleMonth -DateTime $DateTime -Months $Months) -and
|
||||
(Test-ScheduleWeekday -DateTime $DateTime -Weekdays $Weekdays) -and
|
||||
(Test-ScheduleTime -DateTime $DateTime -Times $Times)
|
||||
}
|
||||
|
||||
function Test-Interval {
|
||||
param([datetime]$LastRun, [datetime]$Now, [int]$MinIntervalMinutes)
|
||||
return $Now -ge $LastRun.AddMinutes($MinIntervalMinutes)
|
||||
}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# MAIN SCHEDULING CHECK
|
||||
# ======================================================================
|
||||
function Test-ShouldExecute {
|
||||
param(
|
||||
[switch]$Automated,
|
||||
[string]$CurrentDateTimeUtc,
|
||||
[hashtable]$Config,
|
||||
[string]$LastRunFilePath
|
||||
)
|
||||
|
||||
$result = @{
|
||||
ShouldExecute = $true
|
||||
Now = $null
|
||||
}
|
||||
|
||||
$now = Get-CurrentUtcDateTime -ExternalDateTime $CurrentDateTimeUtc
|
||||
$result.Now = $now
|
||||
|
||||
if ($Automated) {
|
||||
Write-Log "Automated: $Automated" 'Green'
|
||||
Write-Log "Current UTC Time used: $now" 'Green'
|
||||
|
||||
if (-not (Test-Schedule -DateTime $now -Months $Config.RunMonth -Weekdays $Config.RunWeekday -Times $Config.RunTime)) {
|
||||
Write-Log "Execution skipped due to schedule (Month: $($Config.RunMonth), Weekday: $($Config.RunWeekday), Time: $($Config.RunTime))" 'Yellow'
|
||||
$result.ShouldExecute = $false
|
||||
return $result
|
||||
}
|
||||
}
|
||||
|
||||
if ($LastRunFilePath -and (Test-Path $LastRunFilePath)) {
|
||||
$lastRun = Get-Content $LastRunFilePath | Select-Object -First 1
|
||||
if ($lastRun) {
|
||||
[datetime]$lastRunDT = [datetime]::Parse($lastRun)
|
||||
if (-not (Test-Interval -LastRun $lastRunDT -Now $now -MinIntervalMinutes $Config.MinIntervalMinutes)) {
|
||||
Write-Log "Last run at $lastRunDT. Interval not reached." 'Yellow'
|
||||
$result.ShouldExecute = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# LOCK FILE HANDLING
|
||||
# ======================================================================
|
||||
function New-LockGuard {
|
||||
param([string]$LockFile)
|
||||
|
||||
if (Test-Path $LockFile) {
|
||||
Write-Log "Guard: Existing lock file ($LockFile). Skipping execution." 'Red'
|
||||
return $false
|
||||
}
|
||||
|
||||
try {
|
||||
New-Item -Path $LockFile -ItemType File -Force | Out-Null
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Log "Guard: Failed to create lock file ($LockFile). Skipping." 'Red'
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-LockGuard {
|
||||
param([string]$LockFile)
|
||||
if (Test-Path $LockFile) {
|
||||
Remove-Item $LockFile -Force
|
||||
Write-Log "Lock file removed: $LockFile" 'Cyan'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# BUSINESS LOGIC PLACEHOLDER
|
||||
# ======================================================================
|
||||
function Run-BusinessLogic {
|
||||
# Put your actual logic here
|
||||
Write-Log "Executing business logic..." 'Green'
|
||||
|
||||
# ...
|
||||
# ...
|
||||
}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# MAIN EXECUTION FLOW
|
||||
# ======================================================================
|
||||
$scriptPath = $MyInvocation.MyCommand.Path
|
||||
$lastRunFile = [System.IO.Path]::ChangeExtension($scriptPath, ".lastRun")
|
||||
$lockFile = [System.IO.Path]::ChangeExtension($scriptPath, ".lock")
|
||||
|
||||
$schedule = Test-ShouldExecute -Automated:$Automated -CurrentDateTimeUtc $CurrentDateTimeUtc -Config $Config -LastRunFilePath $lastRunFile
|
||||
|
||||
if (-not $schedule.ShouldExecute) {
|
||||
Write-Log "Execution skipped." 'Yellow'
|
||||
return
|
||||
}
|
||||
|
||||
if (-not (New-LockGuard -LockFile $lockFile)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$schedule.Now.ToString("o") | Set-Content $lastRunFile
|
||||
Run-BusinessLogic
|
||||
}
|
||||
finally {
|
||||
Remove-LockGuard -LockFile $lockFile
|
||||
}
|
||||
```
|
||||
|
||||
Thanks to that, it's possible to create standalone scripts or automated scheduled scripts, which will be executed according to the script managed schedule logic.
|
||||
|
||||
For every new scheduled script:
|
||||
* Copy the template.
|
||||
* Modify only the Config block and Run-BusinessLogic.
|
||||
* Leave everything else untouched.
|
||||
|
||||
Done — all scheduling, locking, and logging works automatically.
|
||||
|
||||
### Thread organization
|
||||
|
||||
Every script and program is launched in its **own thread**, so if one crashes, others are able to continue:
|
||||
|
||||
```
|
||||
Unified Scheduler Service Thread
|
||||
├── Powershell
|
||||
│ ├── /Scripts/SomeStuff_1/StartScript.ps1 Thread
|
||||
│ ├── /Scripts/SomeStuff_2/StartScript.ps1 Thread
|
||||
│ └── ...
|
||||
└── Processes
|
||||
├── /Programs/SomeStuff_1/Program.exe
|
||||
├── /Programs/SomeStuff_2/Program.exe
|
||||
└── ...
|
||||
```
|
||||
|
||||
> By default It's set to execute only **signed** scrips, but if you don't care about your environment security, it's possible to launch them in **unrestricted** mode.
|
||||
>
|
||||
> Continue to read to see other possible settings...
|
||||
|
||||
## Configurations
|
||||
|
||||
Here are all currently available configurations inside `appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
//...
|
||||
|
||||
"Configurations": {
|
||||
"ServiceName": "UScheduler",
|
||||
"Description": "Windows service, which allows you to invoke PowerShell Scripts and Processes",
|
||||
"DisplayName": "Unified Scheduler Service",
|
||||
|
||||
"Powershell": [
|
||||
{
|
||||
"Path": "C:\\UScheduler\\Scripts\\Demo\\StartScript.ps1",
|
||||
"Signed": true
|
||||
}
|
||||
],
|
||||
|
||||
"Processes": [
|
||||
{
|
||||
"Path": "C:\\UScheduler\\Programs\\syncthing-windows-amd64-v1.27.1\\syncthing.exe",
|
||||
"Args": [],
|
||||
"RestartOnFailure": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Let's see each one:
|
||||
|
||||
* ServiceName - System service name. I suggest to use short names without spaces or other strange characters. See [What are valid characters in a Windows service (key) name?](https://stackoverflow.com/questions/801280/what-are-valid-characters-in-a-windows-service-key-name).
|
||||
* Description - Description you wants to give to this service. Just put something very serious and technically complex to admire what kind of DUDE you are!
|
||||
* DisplayName - Same thing like for ServiceName, but you are free to use spaces.
|
||||
* Powershell:
|
||||
* ScriptsPath - Specify script to launch.
|
||||
* SignedScripts - **true** for **AllSigned** or **false** for **Unrestricted**.
|
||||
* Processes:
|
||||
* Path - Specify program to launch.
|
||||
* Args - Program command line arguments
|
||||
* RestartOnFailure - Allows to restart if something went wrong with program.
|
||||
131
src/MaksIT.UScheduler/Services/PSScriptService.cs
Normal file
131
src/MaksIT.UScheduler/Services/PSScriptService.cs
Normal file
@ -0,0 +1,131 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Management.Automation;
|
||||
using System.Management.Automation.Runspaces;
|
||||
|
||||
|
||||
namespace MaksIT.UScheduler.Services;
|
||||
|
||||
public sealed class PSScriptService {
|
||||
|
||||
private readonly ILogger<PSScriptService> _logger;
|
||||
private readonly ConcurrentDictionary<string, PowerShell> _runningScripts = new ConcurrentDictionary<string, PowerShell>();
|
||||
private readonly Runspace _rs = RunspaceFactory.CreateRunspace();
|
||||
|
||||
public PSScriptService(ILogger<PSScriptService> logger) {
|
||||
_logger = logger;
|
||||
if (_rs.RunspaceStateInfo.State != RunspaceState.Opened) {
|
||||
_rs.Open();
|
||||
_logger.LogInformation($"Runspace opened");
|
||||
}
|
||||
}
|
||||
|
||||
public Task RunScript(string scriptPath, bool signed, CancellationToken stoppingToken) {
|
||||
_logger.LogInformation($"Preparing to run script {scriptPath}");
|
||||
|
||||
if (GetRunningScriptTasks().Contains(scriptPath)) {
|
||||
_logger.LogInformation($"PowerShell script {scriptPath} is already running");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!File.Exists(scriptPath)) {
|
||||
_logger.LogError($"Script file {scriptPath} does not exist");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!TryUnblockScript(scriptPath)) {
|
||||
_logger.LogError($"Script {scriptPath} could not be unblocked. Aborting execution.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var ps = PowerShell.Create();
|
||||
ps.Runspace = _rs;
|
||||
_runningScripts.TryAdd(scriptPath, ps);
|
||||
|
||||
try {
|
||||
var scriptPolicy = signed ? "AllSigned" : "Unrestricted";
|
||||
ps.AddScript($"Set-ExecutionPolicy -Scope Process -ExecutionPolicy {scriptPolicy}");
|
||||
ps.Invoke();
|
||||
|
||||
if (signed) {
|
||||
ps.Commands.Clear();
|
||||
ps.AddScript($"Get-AuthenticodeSignature \"{scriptPath}\"");
|
||||
var signatureResults = ps.Invoke();
|
||||
if (signatureResults.Count == 0 || ((Signature)signatureResults[0].BaseObject).Status != SignatureStatus.Valid) {
|
||||
_logger.LogWarning($"Script {scriptPath} signature is invalid. Correct and restart the service.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Invoking: {scriptPath}");
|
||||
|
||||
ps.Commands.Clear();
|
||||
var myCommand = new Command(scriptPath);
|
||||
|
||||
var currentDateTimeUtcString = DateTime.UtcNow.ToString("o");
|
||||
myCommand.Parameters.Add(new CommandParameter("Automated", true));
|
||||
myCommand.Parameters.Add(new CommandParameter("CurrentDateTimeUtc", currentDateTimeUtcString));
|
||||
ps.Commands.Commands.Add(myCommand);
|
||||
|
||||
_logger.LogInformation($"Added parameters: Automated=true, CurrentDateTimeUtc={currentDateTimeUtcString}");
|
||||
|
||||
// Log standard output
|
||||
var outputResults = ps.Invoke();
|
||||
if (outputResults != null && outputResults.Count > 0) {
|
||||
foreach (var outputItem in outputResults) {
|
||||
_logger.LogInformation($"[PS Output] {outputItem}");
|
||||
}
|
||||
}
|
||||
|
||||
// Log errors
|
||||
if (ps.Streams.Error.Count > 0) {
|
||||
foreach (var errorItem in ps.Streams.Error) {
|
||||
_logger.LogError($"[PS Error] {errorItem}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
_logger.LogInformation($"Stopping script {scriptPath} due to cancellation request");
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError($"Error running script {scriptPath}: {ex.Message}");
|
||||
}
|
||||
finally {
|
||||
TerminateScript(scriptPath);
|
||||
_logger.LogInformation($"Script {scriptPath} completed and removed from running scripts");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool TryUnblockScript(string scriptPath) {
|
||||
try {
|
||||
var zoneIdentifier = scriptPath + ":Zone.Identifier";
|
||||
if (File.Exists(zoneIdentifier)) {
|
||||
File.Delete(zoneIdentifier);
|
||||
_logger.LogInformation($"Unblocked script {scriptPath} by removing Zone.Identifier.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogWarning($"Failed to unblock script {scriptPath}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public List<string> GetRunningScriptTasks() {
|
||||
_logger.LogInformation($"Retrieving running script tasks. Current count: {_runningScripts.Count}");
|
||||
return _runningScripts.Keys.ToList();
|
||||
}
|
||||
|
||||
public void TerminateScript(string scriptPath) {
|
||||
_logger.LogInformation($"Attempting to terminate script {scriptPath}");
|
||||
|
||||
if (_runningScripts.TryRemove(scriptPath, out var ps)) {
|
||||
ps.Stop();
|
||||
_logger.LogInformation($"Script {scriptPath} terminated");
|
||||
}
|
||||
else {
|
||||
_logger.LogWarning($"Failed to terminate script {scriptPath}. Script not found.");
|
||||
}
|
||||
}
|
||||
}
|
||||
99
src/MaksIT.UScheduler/Services/ProcessService.cs
Normal file
99
src/MaksIT.UScheduler/Services/ProcessService.cs
Normal file
@ -0,0 +1,99 @@
|
||||
using System.Diagnostics;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
|
||||
namespace MaksIT.UScheduler.Services;
|
||||
|
||||
public sealed class ProcessService {
|
||||
|
||||
private readonly ILogger<ProcessService> _logger;
|
||||
private readonly ConcurrentDictionary<int, Process> _runningProcesses = new();
|
||||
|
||||
public ProcessService(ILogger<ProcessService> logger) {
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunProcess(string processPath, string[] args, CancellationToken stoppingToken) {
|
||||
_logger.LogInformation($"Starting process {processPath} with arguments {string.Join(", ", args)}");
|
||||
|
||||
Process? process = null;
|
||||
|
||||
try {
|
||||
if (GetRunningProcesses().Any(x => x.Value.StartInfo.FileName == processPath)) {
|
||||
_logger.LogInformation($"Process {processPath} is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
process = new Process();
|
||||
|
||||
process.StartInfo = new ProcessStartInfo {
|
||||
FileName = processPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(processPath),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
|
||||
foreach (var arg in args)
|
||||
process.StartInfo.ArgumentList.Add(arg);
|
||||
|
||||
process.Start();
|
||||
_runningProcesses.TryAdd(process.Id, process);
|
||||
|
||||
_logger.LogInformation($"Process {processPath} started with ID {process.Id}");
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode != 0 && !stoppingToken.IsCancellationRequested) {
|
||||
_logger.LogWarning($"Process {processPath} exited with code {process.ExitCode}");
|
||||
await RunProcess(processPath, args, stoppingToken);
|
||||
}
|
||||
else {
|
||||
_logger.LogInformation($"Process {processPath} completed successfully");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
// When the stopping token is canceled, for example, a call made from services.msc,
|
||||
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
||||
_logger.LogWarning($"Process {processPath} was canceled");
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError($"Error running process {processPath}: {ex.Message}");
|
||||
}
|
||||
finally {
|
||||
if (process != null && _runningProcesses.ContainsKey(process.Id)) {
|
||||
TerminateProcessById(process.Id);
|
||||
|
||||
_logger.LogInformation($"Process {processPath} with ID {process.Id} removed from running processes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ConcurrentDictionary<int, Process> GetRunningProcesses() {
|
||||
_logger.LogInformation($"Retrieving running processes. Current count: {_runningProcesses.Count}");
|
||||
return _runningProcesses;
|
||||
}
|
||||
|
||||
public void TerminateProcessById(int processId) {
|
||||
// Check if the process is in the running processes list
|
||||
if (!_runningProcesses.TryGetValue(processId, out var processToTerminate)) {
|
||||
_logger.LogWarning($"Failed to terminate process {processId}. Process not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
try {
|
||||
processToTerminate.Kill(true);
|
||||
_logger.LogInformation($"Process {processId} terminated");
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError($"Error terminating process {processId}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Check if the process has exited
|
||||
if (!processToTerminate.HasExited) {
|
||||
_logger.LogWarning($"Failed to terminate process {processId}. Process still running.");
|
||||
TerminateProcessById(processId);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/MaksIT.UScheduler/Uninstall.cmd
Normal file
2
src/MaksIT.UScheduler/Uninstall.cmd
Normal file
@ -0,0 +1,2 @@
|
||||
sc.exe delete "MaksIT.UScheduler Service"
|
||||
pause
|
||||
@ -1,10 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning"
|
||||
"Default": "Information"
|
||||
},
|
||||
"EventLog": {
|
||||
"SourceName": "UScheduler",
|
||||
"SourceName": "MaksIT.UScheduler",
|
||||
"LogName": "Application",
|
||||
"LogLevel": {
|
||||
"Microsoft": "Information",
|
||||
@ -12,11 +12,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Configurations": {
|
||||
"ServiceName": "UScheduler",
|
||||
|
||||
"Configuration": {
|
||||
"Powershell": [
|
||||
|
||||
],
|
||||
|
||||
"Processes": [
|
||||
9
src/Release-ToGitHub.bat
Normal file
9
src/Release-ToGitHub.bat
Normal file
@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
|
||||
REM Change directory to the location of the script
|
||||
cd /d %~dp0
|
||||
|
||||
REM Invoke the PowerShell script (Release-ToGitHub.ps1) in the same directory
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0Release-ToGitHub.ps1"
|
||||
|
||||
pause
|
||||
173
src/Release-ToGitHub.ps1
Normal file
173
src/Release-ToGitHub.ps1
Normal file
@ -0,0 +1,173 @@
|
||||
# Set GH_TOKEN from custom environment variable for GitHub CLI authentication
|
||||
$env:GH_TOKEN = $env:GITHUB_MAKS_IT_COM
|
||||
|
||||
# Paths
|
||||
$csprojPath = "MaksIT.UScheduler\MaksIT.UScheduler.csproj"
|
||||
$publishDir = "publish"
|
||||
$releaseDir = "release"
|
||||
$changelogPath = "..\CHANGELOG.md"
|
||||
|
||||
# Helper: ensure required commands exist
|
||||
function Assert-Command {
|
||||
param([string]$cmd)
|
||||
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||
Write-Error "Required command '$cmd' is missing. Aborting."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-Command dotnet
|
||||
Assert-Command git
|
||||
Assert-Command gh
|
||||
|
||||
# 1. Get version from .csproj
|
||||
[xml]$csproj = Get-Content $csprojPath
|
||||
|
||||
# Support multiple PropertyGroups
|
||||
$version = ($csproj.Project.PropertyGroup |
|
||||
Where-Object { $_.Version } |
|
||||
Select-Object -First 1).Version
|
||||
|
||||
if (-not $version) {
|
||||
Write-Error "Version not found in $csprojPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Version detected: $version"
|
||||
|
||||
# 2. Publish the project
|
||||
Write-Host "Publishing project..."
|
||||
dotnet publish $csprojPath -c Release -o $publishDir
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "dotnet publish failed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 3. Prepare release directory
|
||||
if (!(Test-Path $releaseDir)) {
|
||||
New-Item -ItemType Directory -Path $releaseDir | Out-Null
|
||||
}
|
||||
|
||||
# 4. Create zip file
|
||||
$zipName = "maksit.uscheduler-$version.zip"
|
||||
$zipPath = Join-Path $releaseDir $zipName
|
||||
|
||||
if (Test-Path $zipPath) {
|
||||
Remove-Item $zipPath -Force
|
||||
}
|
||||
|
||||
Write-Host "Creating archive $zipName ..."
|
||||
Compress-Archive -Path "$publishDir\*" -DestinationPath $zipPath -Force
|
||||
|
||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $zipPath)) {
|
||||
Write-Error "Failed to create archive $zipPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Release zip created: $zipPath"
|
||||
|
||||
# 5.a Extract related changelog section from CHANGELOG.md
|
||||
if (-not (Test-Path $changelogPath)) {
|
||||
Write-Error "CHANGELOG.md not found."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$changelog = Get-Content $changelogPath -Raw
|
||||
|
||||
# Regex pattern to get the changelog section for the current version
|
||||
$pattern = "(?ms)^##\s+v$([regex]::Escape($version))\b.*?(?=^##\s+v\d+\.\d+\.\d+|\Z)"
|
||||
|
||||
$match = [regex]::Match($changelog, $pattern)
|
||||
|
||||
if (-not $match.Success) {
|
||||
Write-Error "Changelog entry for version $version not found."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$releaseNotes = $match.Value.Trim()
|
||||
|
||||
Write-Host "Extracted release notes for ${version}:"
|
||||
Write-Host "----------------------------------------"
|
||||
Write-Host $releaseNotes
|
||||
Write-Host "----------------------------------------"
|
||||
|
||||
# 5. Create GitHub Release (requires GitHub CLI)
|
||||
# Get remote URL
|
||||
$remoteUrl = git config --get remote.origin.url
|
||||
if ($LASTEXITCODE -ne 0 -or -not $remoteUrl) {
|
||||
Write-Error "Could not determine git remote origin URL."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Extract owner/repo from URL (supports HTTPS and SSH)
|
||||
if ($remoteUrl -match "[:/](?<owner>[^/]+)/(?<repo>[^/.]+)(\.git)?$") {
|
||||
$owner = $matches['owner']
|
||||
$repoName = $matches['repo']
|
||||
$repo = "$owner/$repoName"
|
||||
} else {
|
||||
Write-Error "Could not parse GitHub repo from remote URL: $remoteUrl"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$tag = "v$version"
|
||||
$releaseName = "Release $version"
|
||||
|
||||
Write-Host "Repository detected: $repo"
|
||||
Write-Host "Tag to be created: $tag"
|
||||
|
||||
# Ensure GH_TOKEN is set
|
||||
if (-not $env:GH_TOKEN) {
|
||||
Write-Error "GH_TOKEN environment variable is not set. Set GITHUB_MAKS_IT_COM and rerun."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Authenticating GitHub CLI using GH_TOKEN..."
|
||||
|
||||
# Reliable authentication test
|
||||
$authTest = gh api user 2>$null
|
||||
|
||||
if ($LASTEXITCODE -ne 0 -or -not $authTest) {
|
||||
Write-Error "GitHub CLI authentication failed. GH_TOKEN may be invalid or missing repo scope."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "GitHub CLI authenticated successfully via GH_TOKEN."
|
||||
|
||||
# Create or replace release
|
||||
Write-Host "Creating GitHub release for $repo ..."
|
||||
|
||||
# Check if release already exists
|
||||
$existing = gh release list --repo $repo | Select-String "^$tag\s"
|
||||
|
||||
if ($existing) {
|
||||
Write-Host "Tag $tag already exists. Deleting old release..."
|
||||
gh release delete $tag --repo $repo --yes
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to delete existing release $tag."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Create new release with extracted changelog section
|
||||
gh release create $tag $zipPath `
|
||||
--repo $repo `
|
||||
--title "${releaseName}" `
|
||||
--notes "${releaseNotes}"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to create GitHub release for tag $tag."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "GitHub release created successfully."
|
||||
|
||||
# Cleanup temporary directories
|
||||
if (Test-Path $publishDir) {
|
||||
Remove-Item $publishDir -Recurse -Force
|
||||
Write-Host "Cleaned up $publishDir directory."
|
||||
}
|
||||
|
||||
# Keep release artifacts
|
||||
Write-Host "Release artifacts kept in: $releaseDir"
|
||||
Write-Host "Done."
|
||||
@ -1,85 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using UScheduler.Services;
|
||||
|
||||
namespace UScheduler.BackgroundServices {
|
||||
|
||||
public sealed class PSScriptBackgroundService : BackgroundService {
|
||||
|
||||
private readonly ILogger<PSScriptBackgroundService> _logger;
|
||||
private readonly Configuration _configuration;
|
||||
private readonly PSScriptService _psScriptService;
|
||||
|
||||
public PSScriptBackgroundService(
|
||||
ILogger<PSScriptBackgroundService> logger,
|
||||
IOptions<Configuration> options,
|
||||
PSScriptService psScriptService
|
||||
) {
|
||||
_logger = logger;
|
||||
_configuration = options.Value;
|
||||
_psScriptService = psScriptService;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
_logger.LogInformation("Starting PSScriptBackgroundService");
|
||||
|
||||
try {
|
||||
var psScripts = _configuration.PowershellOrDefault;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested) {
|
||||
_logger.LogInformation("Checking for PowerShell scripts to run");
|
||||
|
||||
//stop background service if there are no PowerShell scripts to run
|
||||
if (psScripts.Count == 0) {
|
||||
_logger.LogWarning("No PowerShell scripts to run, stopping PSScriptBackgroundService");
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var psScript in psScripts) {
|
||||
var scriptPath = psScript.GetPathOrDefault;
|
||||
|
||||
if (scriptPath == string.Empty)
|
||||
continue;
|
||||
|
||||
_logger.LogInformation($"Running PowerShell script {scriptPath}");
|
||||
_psScriptService.RunScript(scriptPath, psScript.GetIsSignedOrDefault, stoppingToken);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
// When the stopping token is canceled, for example, a call made from services.msc,
|
||||
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
||||
_logger.LogInformation("Stopping PSScriptBackgroundService due to cancellation request");
|
||||
_psScriptService.TerminateAllScripts();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, "{Message}", ex.Message);
|
||||
|
||||
_psScriptService.TerminateAllScripts();
|
||||
|
||||
// Terminates this process and returns an exit code to the operating system.
|
||||
// This is required to avoid the 'BackgroundServiceExceptionBehavior', which
|
||||
// performs one of two scenarios:
|
||||
// 1. When set to "Ignore": will do nothing at all, errors cause zombie services.
|
||||
// 2. When set to "StopHost": will cleanly stop the host, and log errors.
|
||||
//
|
||||
// In order for the Windows Service Management system to leverage configured
|
||||
// recovery options, we need to terminate the process with a non-zero exit code.
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public override Task StopAsync(CancellationToken stoppingToken) {
|
||||
// Perform cleanup tasks here
|
||||
_logger.LogInformation("Stopping PSScriptBackgroundService");
|
||||
|
||||
_psScriptService.TerminateAllScripts();
|
||||
|
||||
_logger.LogInformation("PSScriptBackgroundService stopped");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace UScheduler {
|
||||
|
||||
public class PowershellScript {
|
||||
public string? Path { get; set; }
|
||||
public bool? IsSigned { get; set; }
|
||||
|
||||
public string GetPathOrDefault => Path ?? string.Empty;
|
||||
public bool GetIsSignedOrDefault => IsSigned ?? false;
|
||||
}
|
||||
|
||||
public class ProcessConfiguration {
|
||||
public string? Path { get; set; }
|
||||
public string[]? Args { get; set; }
|
||||
public bool? RestartOnFailure { get; set; }
|
||||
|
||||
public string GetPathOrDefault => Path ?? string.Empty;
|
||||
public string[] GetArgsOrDefault => Args ?? [];
|
||||
public bool GetRestartOnFailureOrDefault => RestartOnFailure ?? false;
|
||||
}
|
||||
|
||||
public class Configuration {
|
||||
|
||||
public string? ServiceName { get; set; }
|
||||
|
||||
public List<PowershellScript>? Powershell { get; set; }
|
||||
|
||||
public List<ProcessConfiguration>? Processes { get; set; }
|
||||
|
||||
public string ServiceNameOrDefault => ServiceName ?? string.Empty;
|
||||
|
||||
public List<PowershellScript> PowershellOrDefault => Powershell ?? [];
|
||||
|
||||
public List<ProcessConfiguration> ProcessesOrDefault => Processes ?? [];
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
sc.exe create "Unified Scheduler Service" binpath="%~dp0UScheduler.exe"
|
||||
sc description "Unified Scheduler Service" "Windows service, which allows you to invoke PowerShell Scripts and Processes"
|
||||
pause
|
||||
@ -1,38 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Configuration;
|
||||
using Microsoft.Extensions.Logging.EventLog;
|
||||
using System.Runtime.InteropServices;
|
||||
using UScheduler;
|
||||
using UScheduler.BackgroundServices;
|
||||
using UScheduler.Services;
|
||||
|
||||
// read configuration from appsettings.json
|
||||
var configurationRoot = new ConfigurationBuilder()
|
||||
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true)
|
||||
.Build();
|
||||
|
||||
// bind Configuration section inside configuration to a new instance of Settings
|
||||
var configuration = new Configuration();
|
||||
configurationRoot.GetSection("Configurations").Bind(configuration);
|
||||
|
||||
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Services.AddWindowsService(options => {
|
||||
options.ServiceName = configuration.ServiceNameOrDefault;
|
||||
});
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||
LoggerProviderOptions.RegisterProviderOptions<
|
||||
EventLogSettings, EventLogLoggerProvider>(builder.Services);
|
||||
}
|
||||
|
||||
builder.Services.Configure<Configuration>(configurationRoot.GetSection("Configurations"));
|
||||
|
||||
builder.Services.AddSingleton<ProcessService>();
|
||||
builder.Services.AddHostedService<ProcessBackgroundService>();
|
||||
|
||||
builder.Services.AddSingleton<PSScriptService>();
|
||||
builder.Services.AddHostedService<PSScriptBackgroundService>();
|
||||
|
||||
IHost host = builder.Build();
|
||||
host.Run();
|
||||
@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>bin\Release\net8.0\win-x64\publish\win-x64\</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<_TargetId>Folder</_TargetId>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@ -1,108 +0,0 @@
|
||||
using System.Management.Automation;
|
||||
using System.Management.Automation.Runspaces;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace UScheduler.Services {
|
||||
public sealed class PSScriptService {
|
||||
|
||||
private readonly ILogger<PSScriptService> _logger;
|
||||
private readonly ConcurrentDictionary<string, PowerShell> _runningScripts = new ConcurrentDictionary<string, PowerShell>();
|
||||
private readonly Runspace _rs = RunspaceFactory.CreateRunspace();
|
||||
|
||||
public PSScriptService(ILogger<PSScriptService> logger) {
|
||||
_logger = logger;
|
||||
if (_rs.RunspaceStateInfo.State != RunspaceState.Opened) {
|
||||
_rs.Open();
|
||||
_logger.LogInformation($"Runspace opened");
|
||||
}
|
||||
}
|
||||
|
||||
public Task RunScript(string scriptPath, bool signed, CancellationToken stoppingToken) {
|
||||
_logger.LogInformation($"Preparing to run script {scriptPath}");
|
||||
|
||||
if (GetRunningScriptTasks().Contains(scriptPath)) {
|
||||
_logger.LogInformation($"PowerShell script {scriptPath} is already running");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!File.Exists(scriptPath)) {
|
||||
_logger.LogError($"Script file {scriptPath} does not exist");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var ps = PowerShell.Create();
|
||||
ps.Runspace = _rs;
|
||||
_runningScripts.TryAdd(scriptPath, ps);
|
||||
|
||||
try {
|
||||
var scriptPolicy = "Unrestricted";
|
||||
if (signed)
|
||||
scriptPolicy = "AllSigned";
|
||||
|
||||
ps.AddScript($"Set-ExecutionPolicy -Scope Process -ExecutionPolicy {scriptPolicy}");
|
||||
ps.Invoke();
|
||||
|
||||
ps.AddScript($"Get-AuthenticodeSignature \"{scriptPath}\"");
|
||||
|
||||
foreach (var result in ps.Invoke()) {
|
||||
if (signed) {
|
||||
if (((Signature)result.BaseObject).Status != SignatureStatus.Valid) {
|
||||
_logger.LogWarning($"Script {Directory.GetParent(scriptPath)?.Name} Signature Error! Correct, and restart the service.");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Invoking: {scriptPath}");
|
||||
|
||||
var myCommand = new Command(scriptPath);
|
||||
|
||||
// Pass -Automated switch and -CuttrentDateTimeUtc, as UTC ISO 8601 string
|
||||
myCommand.Parameters.Add(new CommandParameter("Automated", true));
|
||||
myCommand.Parameters.Add(new CommandParameter("CurrentDateTimeUtc", DateTime.UtcNow.ToString("o")));
|
||||
|
||||
ps.Commands.Commands.Add(myCommand);
|
||||
ps.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
catch (OperationCanceledException) {
|
||||
// When the stopping token is canceled, for example, a call made from services.msc,
|
||||
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
||||
_logger.LogInformation($"Stopping script {scriptPath} due to cancellation request");
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError($"Error running script {scriptPath}: {ex.Message}");
|
||||
}
|
||||
finally {
|
||||
TerminateScript(scriptPath);
|
||||
_logger.LogInformation($"Script {scriptPath} completed and removed from running scripts");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public List<string> GetRunningScriptTasks() {
|
||||
_logger.LogInformation($"Retrieving running script tasks. Current count: {_runningScripts.Count}");
|
||||
return _runningScripts.Keys.ToList();
|
||||
}
|
||||
|
||||
public void TerminateScript(string scriptPath) {
|
||||
_logger.LogInformation($"Attempting to terminate script {scriptPath}");
|
||||
|
||||
if (_runningScripts.TryRemove(scriptPath, out var ps)) {
|
||||
ps.Stop();
|
||||
_logger.LogInformation($"Script {scriptPath} terminated");
|
||||
}
|
||||
else {
|
||||
_logger.LogWarning($"Failed to terminate script {scriptPath}. Script not found.");
|
||||
}
|
||||
}
|
||||
|
||||
public void TerminateAllScripts() {
|
||||
foreach (var script in _runningScripts) {
|
||||
TerminateScript(script.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,104 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace UScheduler.Services {
|
||||
public sealed class ProcessService {
|
||||
private readonly ILogger<ProcessService> _logger;
|
||||
private readonly ConcurrentDictionary<int, Process> _runningProcesses = new();
|
||||
|
||||
public ProcessService(ILogger<ProcessService> logger) {
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunProcess(string processPath, string[] args, CancellationToken stoppingToken) {
|
||||
_logger.LogInformation($"Starting process {processPath} with arguments {string.Join(", ", args)}");
|
||||
|
||||
Process? process = null;
|
||||
|
||||
try {
|
||||
if (GetRunningProcesses().Any(x => x.Value.StartInfo.FileName == processPath)) {
|
||||
_logger.LogInformation($"Process {processPath} is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
process = new Process();
|
||||
|
||||
process.StartInfo = new ProcessStartInfo {
|
||||
FileName = processPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(processPath),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
|
||||
foreach (var arg in args)
|
||||
process.StartInfo.ArgumentList.Add(arg);
|
||||
|
||||
process.Start();
|
||||
_runningProcesses.TryAdd(process.Id, process);
|
||||
|
||||
_logger.LogInformation($"Process {processPath} started with ID {process.Id}");
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode != 0 && !stoppingToken.IsCancellationRequested) {
|
||||
_logger.LogWarning($"Process {processPath} exited with code {process.ExitCode}");
|
||||
await RunProcess(processPath, args, stoppingToken);
|
||||
}
|
||||
else {
|
||||
_logger.LogInformation($"Process {processPath} completed successfully");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
// When the stopping token is canceled, for example, a call made from services.msc,
|
||||
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
||||
_logger.LogWarning($"Process {processPath} was canceled");
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError($"Error running process {processPath}: {ex.Message}");
|
||||
}
|
||||
finally {
|
||||
if (process != null && _runningProcesses.ContainsKey(process.Id)) {
|
||||
TerminateProcessById(process.Id);
|
||||
|
||||
_logger.LogInformation($"Process {processPath} with ID {process.Id} removed from running processes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ConcurrentDictionary<int, Process> GetRunningProcesses() {
|
||||
_logger.LogInformation($"Retrieving running processes. Current count: {_runningProcesses.Count}");
|
||||
return _runningProcesses;
|
||||
}
|
||||
|
||||
public void TerminateProcessById(int processId) {
|
||||
// Check if the process is in the running processes list
|
||||
if (!_runningProcesses.TryGetValue(processId, out var processToTerminate)) {
|
||||
_logger.LogWarning($"Failed to terminate process {processId}. Process not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
try {
|
||||
processToTerminate.Kill(true);
|
||||
_logger.LogInformation($"Process {processId} terminated");
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError($"Error terminating process {processId}: {ex.Message}");
|
||||
}
|
||||
|
||||
// Check if the process has exited
|
||||
if (!processToTerminate.HasExited) {
|
||||
_logger.LogWarning($"Failed to terminate process {processId}. Process still running.");
|
||||
TerminateProcessById(processId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void TerminateAllProcesses() {
|
||||
foreach (var process in _runningProcesses) {
|
||||
TerminateProcessById(process.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
sc.exe delete "Unified Scheduler Service"
|
||||
pause
|
||||
@ -1,31 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning"
|
||||
},
|
||||
"EventLog": {
|
||||
"SourceName": "UScheduler",
|
||||
"LogName": "Application",
|
||||
"LogLevel": {
|
||||
"Microsoft": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Configurations": {
|
||||
"ServiceName": "UScheduler",
|
||||
|
||||
"Powershell": [
|
||||
|
||||
],
|
||||
|
||||
"Processes": [
|
||||
{
|
||||
"Path": "C:\\Users\\maksym\\Desktop\\Programs\\syncthing-windows-amd64-v1.27.1\\syncthing.exe",
|
||||
"Args": ["--no-restart", "--home=C:\\Users\\maksym\\Desktop\\Data\\Syncthing"],
|
||||
"RestartOnFailure": true
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user