mirror of
https://github.com/MAKS-IT-COM/uscheduler.git
synced 2026-04-01 08:42:11 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a98ca040f7 | ||
|
|
4e935ca1e4 | ||
|
|
6b95fcd0b2 |
@ -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/),
|
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).
|
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
|
## v1.0.1 - 2026-02-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -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%">
|
<svg xmlns="http://www.w3.org/2000/svg" width="147.5" height="20" role="img" aria-label="Branch Coverage: 7%">
|
||||||
<title>Branch Coverage: 9.6%</title>
|
<title>Branch Coverage: 7%</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" 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">
|
<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 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 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 aria-hidden="true" x="127.5" y="15" fill="#010101" fill-opacity=".3">7%</text>
|
||||||
<text x="127.5" y="14" fill="#fff">9.6%</text>
|
<text x="127.5" y="14" fill="#fff">7%</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 18.6%">
|
<svg xmlns="http://www.w3.org/2000/svg" width="137" height="20" role="img" aria-label="Line Coverage: 15.2%">
|
||||||
<title>Line Coverage: 18.6%</title>
|
<title>Line Coverage: 15.2%</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" 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">
|
<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 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 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 aria-hidden="true" x="115.75" y="15" fill="#010101" fill-opacity=".3">15.2%</text>
|
||||||
<text x="115.75" y="14" fill="#fff">18.6%</text>
|
<text x="115.75" y="14" fill="#fff">15.2%</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 43.2%">
|
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="Method Coverage: 38.8%">
|
||||||
<title>Method Coverage: 43.2%</title>
|
<title>Method Coverage: 38.8%</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
@ -9,13 +9,13 @@
|
|||||||
</clipPath>
|
</clipPath>
|
||||||
<g clip-path="url(#r)">
|
<g clip-path="url(#r)">
|
||||||
<rect width="107.5" height="20" fill="#555"/>
|
<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)"/>
|
<rect width="150" height="20" fill="url(#s)"/>
|
||||||
</g>
|
</g>
|
||||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
<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 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 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 aria-hidden="true" x="128.75" y="15" fill="#010101" fill-opacity=".3">38.8%</text>
|
||||||
<text x="128.75" y="14" fill="#fff">43.2%</text>
|
<text x="128.75" y="14" fill="#fff">38.8%</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -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).
|
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Version>1.0.1</Version>
|
<Version>1.0.2</Version>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>
|
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Management.Automation;
|
using System.Management.Automation;
|
||||||
|
using System.Management.Automation.Language;
|
||||||
using System.Management.Automation.Runspaces;
|
using System.Management.Automation.Runspaces;
|
||||||
using MaksIT.UScheduler.Shared.Helpers;
|
using MaksIT.UScheduler.Shared.Helpers;
|
||||||
using MaksIT.UScheduler.Shared.Extensions;
|
using MaksIT.UScheduler.Shared.Extensions;
|
||||||
@ -63,8 +64,8 @@ public sealed class PSScriptService : IPSScriptService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryUnblockScript(resolvedPath)) {
|
if (!EnsureDependenciesUnblocked(resolvedPath)) {
|
||||||
_logger.LogError($"Script {resolvedPath} could not be unblocked. Aborting execution.");
|
_logger.LogError($"Script or dependencies for {resolvedPath} could not be unblocked. Aborting execution.");
|
||||||
return;
|
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>
|
/// <summary>
|
||||||
/// Gets a list of script paths that are currently being executed.
|
/// Gets a list of script paths that are currently being executed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -214,4 +194,155 @@ public sealed class PSScriptService : IPSScriptService {
|
|||||||
_runspacePool?.Dispose();
|
_runspacePool?.Dispose();
|
||||||
_logger.LogInformation("RunspacePool disposed");
|
_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -645,15 +645,25 @@ if (-not $isDevBranch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Create new release using existing tag
|
# Create new release using existing tag
|
||||||
|
# Write release notes to a temp file to avoid shell interpretation issues with special characters
|
||||||
|
$notesFilePath = Join-Path $releaseDir "release-notes-temp.md"
|
||||||
|
[System.IO.File]::WriteAllText($notesFilePath, $releaseNotes, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
$ghArgs = @(
|
$ghArgs = @(
|
||||||
"release", "create", $tag, $zipPath
|
"release", "create", $tag, $zipPath
|
||||||
"--repo", $repo
|
"--repo", $repo
|
||||||
"--title", $releaseName
|
"--title", $releaseName
|
||||||
"--notes", $releaseNotes
|
"--notes-file", $notesFilePath
|
||||||
)
|
)
|
||||||
& gh @ghArgs
|
& gh @ghArgs
|
||||||
|
$ghExitCode = $LASTEXITCODE
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
# Cleanup temp notes file
|
||||||
|
if (Test-Path $notesFilePath) {
|
||||||
|
Remove-Item $notesFilePath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ghExitCode -ne 0) {
|
||||||
Write-Error "Failed to create GitHub release for tag $tag."
|
Write-Error "Failed to create GitHub release for tag $tag."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user