Merge branch 'dev'

This commit is contained in:
Maksym Sadovnychyy 2026-02-16 21:39:40 +01:00
commit 4e935ca1e4
8 changed files with 179 additions and 419 deletions

View File

@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.0.2 - 2026-03-01
### Fixed
- **PowerShell module loading**: Scripts with module dependencies now execute correctly when running as Windows service
- Added recursive dependency scanning using PowerShell AST parser
- Automatically unblocks `Import-Module`, `using module`, and dot-sourced (`. ./file.ps1`) dependencies
- Resolves "AuthorizationManager check failed" errors for modules downloaded from the internet
- Supports `.psm1` and `.psd1` module files in script directory and subfolders
## v1.0.1 - 2026-02-15
### Added

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 9.6%">
<title>Branch Coverage: 9.6%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 7%">
<title>Branch Coverage: 7%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Branch Coverage</text>
<text x="53.75" y="14" fill="#fff">Branch Coverage</text>
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">9.6%</text>
<text x="127.5" y="14" fill="#fff">9.6%</text>
<text aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">7%</text>
<text x="127.5" y="14" fill="#fff">7%</text>
</g>
</svg>

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 18.6%">
<title>Line Coverage: 18.6%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 15.2%">
<title>Line Coverage: 15.2%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="47.25" y="15" fill="#010101" fill-opacity=".3">Line Coverage</text>
<text x="47.25" y="14" fill="#fff">Line Coverage</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">18.6%</text>
<text x="115.75" y="14" fill="#fff">18.6%</text>
<text aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">15.2%</text>
<text x="115.75" y="14" fill="#fff">15.2%</text>
</g>
</svg>

View File

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 43.2%">
<title>Method Coverage: 43.2%</title>
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 38.8%">
<title>Method Coverage: 38.8%</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@ -9,13 +9,13 @@
</clipPath>
<g clip-path="url(#r)">
<rect width="107.5" height="20" fill="#555"/>
<rect x="107.5" width="42.5" height="20" fill="#a4a61d"/>
<rect x="107.5" width="42.5" height="20" fill="#dfb317"/>
<rect width="150" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text aria-hidden="true" x="53.75" y="15" fill="#010101" fill-opacity=".3">Method Coverage</text>
<text x="53.75" y="14" fill="#fff">Method Coverage</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">43.2%</text>
<text x="128.75" y="14" fill="#fff">43.2%</text>
<text aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">38.8%</text>
<text x="128.75" y="14" fill="#fff">38.8%</text>
</g>
</svg>

View File

@ -1,23 +0,0 @@
# 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

@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>1.0.1</Version>
<Version>1.0.2</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>

View File

@ -1,357 +0,0 @@
# 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

@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Management.Automation;
using System.Management.Automation.Language;
using System.Management.Automation.Runspaces;
using MaksIT.UScheduler.Shared.Helpers;
using MaksIT.UScheduler.Shared.Extensions;
@ -50,7 +51,7 @@ public sealed class PSScriptService : IPSScriptService {
public async Task RunScriptAsync(string scriptPath, bool signed, CancellationToken stoppingToken) {
// Resolve relative paths against application base directory
var resolvedPath = PathHelper.ResolvePath(scriptPath);
_logger.LogInformation($"Preparing to run script {resolvedPath}");
if (GetRunningScriptTasks().Contains(resolvedPath)) {
@ -63,8 +64,8 @@ public sealed class PSScriptService : IPSScriptService {
return;
}
if (!TryUnblockScript(resolvedPath)) {
_logger.LogError($"Script {resolvedPath} could not be unblocked. Aborting execution.");
if (!EnsureDependenciesUnblocked(resolvedPath)) {
_logger.LogError($"Script or dependencies for {resolvedPath} could not be unblocked. Aborting execution.");
return;
}
@ -140,27 +141,6 @@ public sealed class PSScriptService : IPSScriptService {
}
}
/// <summary>
/// Attempts to unblock a downloaded script by removing the Zone.Identifier alternate data stream.
/// This is equivalent to right-clicking a file and selecting "Unblock" in Windows.
/// </summary>
/// <param name="scriptPath">The path to the script to unblock.</param>
/// <returns>True if the script was successfully unblocked or was not blocked; false if unblocking failed.</returns>
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;
}
}
/// <summary>
/// Gets a list of script paths that are currently being executed.
/// </summary>
@ -214,4 +194,155 @@ public sealed class PSScriptService : IPSScriptService {
_runspacePool?.Dispose();
_logger.LogInformation("RunspacePool disposed");
}
}
/// <summary>
/// Recursively scans a PowerShell script for module and dot-sourced dependencies and unblocks them.
/// </summary>
/// <param name="scriptPath">The entry script path.</param>
/// <returns>True if all scripts and dependencies were unblocked; false otherwise.</returns>
private bool EnsureDependenciesUnblocked(string scriptPath) {
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var queue = new Queue<string>();
queue.Enqueue(scriptPath);
bool allUnblocked = true;
while (queue.Count > 0) {
var current = queue.Dequeue();
if (!visited.Add(current))
continue;
if (!TryUnblockScript(current)) {
_logger.LogError($"Failed to unblock dependency: {current}");
allUnblocked = false;
}
// Scan for dependencies
try {
if (!File.Exists(current))
continue;
var currentDir = Path.GetDirectoryName(current);
var ast = Parser.ParseFile(current, out var tokens, out var errors);
// Handle 'using module' statements (UsingStatementAst)
foreach (var usingAst in ast.FindAll(a => a is UsingStatementAst, true)) {
var usingStmt = (UsingStatementAst)usingAst;
if (usingStmt.UsingStatementKind == UsingStatementKind.Module && usingStmt.Name != null) {
var depName = usingStmt.Name.Value;
var depPath = ResolveModulePath(depName, currentDir);
if (!string.IsNullOrEmpty(depPath))
queue.Enqueue(depPath);
}
}
// Handle Import-Module commands
foreach (var cmdAst in ast.FindAll(a => a is CommandAst, true)) {
var cmd = (CommandAst)cmdAst;
var name = cmd.GetCommandName();
if (string.Equals(name, "Import-Module", StringComparison.OrdinalIgnoreCase)) {
foreach (var arg in cmd.CommandElements.Skip(1)) {
var depName = arg.ToString().Trim('"', '\'', ' ');
// Skip parameters like -Name, -Force, etc.
if (depName.StartsWith("-", StringComparison.Ordinal))
continue;
var depPath = ResolveModulePath(depName, currentDir);
if (!string.IsNullOrEmpty(depPath))
queue.Enqueue(depPath);
}
}
}
// Handle dot-sourcing: . ./file.ps1
foreach (var cmdAst in ast.FindAll(a => a is CommandAst, true)) {
var cmd = (CommandAst)cmdAst;
if (cmd.InvocationOperator == TokenKind.Dot) {
var arg = cmd.CommandElements.FirstOrDefault();
if (arg != null && !string.IsNullOrEmpty(currentDir)) {
var depName = arg.ToString().Trim('"', '\'', ' ');
var depPath = Path.Combine(currentDir, depName);
if (File.Exists(depPath))
queue.Enqueue(depPath);
}
}
}
}
catch (Exception ex) {
_logger.LogWarning($"Dependency scan failed for {current}: {ex.Message}");
}
}
return allUnblocked;
}
/// <summary>
/// Attempts to resolve a module path from a module name or path.
/// </summary>
/// <param name="moduleName">Module name or path.</param>
/// <param name="baseDir">Base directory for relative paths.</param>
/// <returns>Resolved module file path or null.</returns>
private string? ResolveModulePath(string moduleName, string? baseDir) {
// If it's a path, resolve relative to baseDir
if (moduleName.EndsWith(".psm1", StringComparison.OrdinalIgnoreCase) || moduleName.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase)) {
if (Path.IsPathRooted(moduleName)) {
if (File.Exists(moduleName))
return moduleName;
}
else if (!string.IsNullOrEmpty(baseDir)) {
var path = Path.Combine(baseDir, moduleName);
if (File.Exists(path))
return path;
}
}
// Try to find module in same directory
if (!string.IsNullOrEmpty(baseDir)) {
var psm1 = Path.Combine(baseDir, moduleName + ".psm1");
if (File.Exists(psm1))
return psm1;
var psd1 = Path.Combine(baseDir, moduleName + ".psd1");
if (File.Exists(psd1))
return psd1;
// Try subfolder with module name (common module structure)
var subPsm1 = Path.Combine(baseDir, moduleName, moduleName + ".psm1");
if (File.Exists(subPsm1))
return subPsm1;
var subPsd1 = Path.Combine(baseDir, moduleName, moduleName + ".psd1");
if (File.Exists(subPsd1))
return subPsd1;
}
return null;
}
/// <summary>
/// Attempts to unblock a downloaded script by removing the Zone.Identifier alternate data stream.
/// This is equivalent to right-clicking a file and selecting "Unblock" in Windows.
/// </summary>
/// <param name="scriptPath">The path to the script to unblock.</param>
/// <returns>True if the script was successfully unblocked or was not blocked; false if unblocking failed.</returns>
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;
}
}
}