(feature): migrate to .net10

This commit is contained in:
Maksym Sadovnychyy 2025-12-06 12:49:46 +01:00
parent 635423b083
commit aa5b446d8b
30 changed files with 1424 additions and 588 deletions

23
CHANGELOG.md Normal file
View 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).

View File

@ -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
View File

@ -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`
Thats it — the full scheduling engine is reused automatically.
---
## Security
* Signed scripts required by default.
* Scripts are auto-unblocked before execution.
* Unrestricted execution can be enabled if needed (not recommended on production systems).
---
## Logging
* Console logging
* File logging under the directory specified by `LogDir`
* All events (start, stop, crash, restart, error, skip) are logged
---
## Contact
Maksym Sadovnychyy MAKS-IT, 2025
Email: maksym.sadovnychyy@gmail.com
---
## License
MIT License
Copyright (c) 2025
Maksym Sadovnychyy MAKS-IT
maksym.sadovnychyy@gmail.com
---
# Appendix
## SchedulerTemplate.psm1 (Full Source)
```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 *
```

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;

View 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).

View 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; } = [];
}

View 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

View 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.

View File

@ -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>

View 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

View 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();

View 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.

View 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.");
}
}
}

View 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);
}
}
}

View File

@ -0,0 +1,2 @@
sc.exe delete "MaksIT.UScheduler Service"
pause

View File

@ -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
View 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
View 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."

View File

@ -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;
}
}
}

View File

@ -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 ?? [];
}
}

View File

@ -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

View File

@ -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();

View File

@ -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>

View File

@ -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);
}
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -1,2 +0,0 @@
sc.exe delete "Unified Scheduler Service"
pause

View File

@ -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
}
]
}
}