mirror of
https://github.com/MAKS-IT-COM/uscheduler.git
synced 2026-02-14 06:37:18 +01:00
(feature): 1.0.1 init
This commit is contained in:
parent
986eea321e
commit
5805664c3e
65
CHANGELOG.md
65
CHANGELOG.md
@ -1,5 +1,44 @@
|
||||
# MaksIT.UScheduler Changelog
|
||||
|
||||
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.1 - 2026-02-02
|
||||
|
||||
### Added
|
||||
- **CLI service management**: Added command-line arguments for service installation and management
|
||||
- `--install`, `-i`: Install the Windows service
|
||||
- `--uninstall`, `-u`: Uninstall the Windows service
|
||||
- `--start`: Start the service
|
||||
- `--stop`: Stop the service
|
||||
- `--status`: Query service status
|
||||
- `--help`, `-h`: Show help message
|
||||
- **Relative path support**: Script and process paths can now be relative to the application directory
|
||||
- **OS guard**: Application now checks for Windows at startup and exits with error on unsupported platforms
|
||||
- **Release script enhancement**: Example scripts are now automatically added to `appsettings.json` in disabled state during release build
|
||||
- **Unit tests**: Added comprehensive test project `MaksIT.UScheduler.Tests` with tests for background services and configuration
|
||||
|
||||
### Changed
|
||||
- Default value for `IsSigned` in PowerShell script configuration changed from `false` to `true` for improved security
|
||||
- PSScriptService now implements `IDisposable` for proper RunspacePool cleanup
|
||||
- Method signatures updated: `RunScript` → `RunScriptAsync`, `RunProcess` → `RunProcessAsync`
|
||||
- Service is now installed with `start=auto` for automatic startup on boot
|
||||
- Updated package dependencies:
|
||||
- MaksIT.Core: 1.6.0 → 1.6.1
|
||||
- Microsoft.Extensions.Hosting: 10.0.0 → 10.0.2
|
||||
- Microsoft.Extensions.Hosting.WindowsServices: 10.0.0 → 10.0.2
|
||||
- System.Diagnostics.PerformanceCounter: 10.0.0 → 10.0.2
|
||||
|
||||
### Removed
|
||||
- `Install.cmd` and `Uninstall.cmd` files (replaced by CLI arguments)
|
||||
|
||||
### Fixed
|
||||
- **Parallel execution**: Restored parallel execution of PowerShell scripts and processes (broken during .NET Framework to .NET migration)
|
||||
- PSScriptService now uses RunspacePool (up to CPU core count) for concurrent script execution
|
||||
- Background services use `Task.WhenAll` to launch all tasks simultaneously
|
||||
|
||||
## v1.0.0 - 2025-12-06
|
||||
|
||||
### Major Changes
|
||||
@ -13,7 +52,7 @@
|
||||
- `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 install/uninstall scripts (`Install.cmd`, `Uninstall.cmd`) for service management (removed in v1.0.1).
|
||||
- Added comprehensive README with usage, configuration, and scheduling examples.
|
||||
- MIT License included.
|
||||
|
||||
@ -21,3 +60,27 @@
|
||||
- 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).
|
||||
|
||||
<!--
|
||||
Template for new releases:
|
||||
|
||||
## v1.x.x - YYYY-MM-DD
|
||||
|
||||
### Added
|
||||
- New features
|
||||
|
||||
### Changed
|
||||
- Changes in existing functionality
|
||||
|
||||
### Deprecated
|
||||
- Soon-to-be removed features
|
||||
|
||||
### Removed
|
||||
- Removed features
|
||||
|
||||
### Fixed
|
||||
- Bug fixes
|
||||
|
||||
### Security
|
||||
- Security improvements
|
||||
-->
|
||||
|
||||
236
CONTRIBUTING.md
Normal file
236
CONTRIBUTING.md
Normal file
@ -0,0 +1,236 @@
|
||||
# Contributing to MaksIT.UScheduler
|
||||
|
||||
Thank you for your interest in contributing to MaksIT.UScheduler!
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Contributing to MaksIT.UScheduler](#contributing-to-maksituscheduler)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Branch Strategy](#branch-strategy)
|
||||
- [Making Changes](#making-changes)
|
||||
- [Versioning](#versioning)
|
||||
- [Release Process](#release-process)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Version Files](#version-files)
|
||||
- [Branch-Based Release Behavior](#branch-based-release-behavior)
|
||||
- [Development Build Workflow](#development-build-workflow)
|
||||
- [Production Release Workflow](#production-release-workflow)
|
||||
- [Release Script Details](#release-script-details)
|
||||
- [Changelog Guidelines](#changelog-guidelines)
|
||||
- [AI-Powered Changelog Generation (Optional)](#ai-powered-changelog-generation-optional)
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/MaksIT/uscheduler.git
|
||||
cd uscheduler
|
||||
```
|
||||
|
||||
2. Open the solution in Visual Studio or your preferred IDE:
|
||||
```
|
||||
src/MaksIT.UScheduler/MaksIT.UScheduler.sln
|
||||
```
|
||||
|
||||
3. Build the project:
|
||||
```bash
|
||||
dotnet build src/MaksIT.UScheduler/MaksIT.UScheduler.csproj
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
- `main` - Production-ready code
|
||||
- `dev` - Active development branch
|
||||
- Feature branches - Created from `dev` for specific features
|
||||
|
||||
---
|
||||
|
||||
## Making Changes
|
||||
|
||||
1. Create a feature branch from `dev`
|
||||
2. Make your changes
|
||||
3. Update `CHANGELOG.md` with your changes
|
||||
4. Update version in `.csproj` if needed
|
||||
5. Test your changes locally using dev tags
|
||||
6. Submit a pull request to `dev`
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
This project follows [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- **MAJOR** - Incompatible API changes
|
||||
- **MINOR** - New functionality (backwards compatible)
|
||||
- **PATCH** - Bug fixes (backwards compatible)
|
||||
|
||||
Version format: `X.Y.Z` (e.g., `1.0.1`)
|
||||
|
||||
---
|
||||
|
||||
## Release Process
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET SDK installed
|
||||
- Git CLI
|
||||
- GitHub CLI (`gh`) - required only for production releases
|
||||
- GitHub token set in environment variable (configured in `scriptsettings.json`)
|
||||
|
||||
### Version Files
|
||||
|
||||
Before creating a release, ensure version consistency across:
|
||||
|
||||
1. **`.csproj`** - Update `<Version>` element:
|
||||
```xml
|
||||
<Version>1.0.1</Version>
|
||||
```
|
||||
|
||||
2. **`CHANGELOG.md`** - Add version entry at the top:
|
||||
```markdown
|
||||
## v1.0.1
|
||||
|
||||
### Added
|
||||
- New feature description
|
||||
|
||||
### Fixed
|
||||
- Bug fix description
|
||||
```
|
||||
|
||||
### Branch-Based Release Behavior
|
||||
|
||||
The release script behavior is controlled by the current branch (configurable in `scriptsettings.json`):
|
||||
|
||||
| Branch | Tag Required | Uncommitted Changes | Behavior |
|
||||
|--------|--------------|---------------------|----------|
|
||||
| Dev (`dev`) | No | Allowed | Local build only (version from .csproj) |
|
||||
| Release (`main`) | Yes | Not allowed | Full release to GitHub |
|
||||
| Other | - | - | Blocked |
|
||||
|
||||
Branch names can be customized in `scriptsettings.json`:
|
||||
```json
|
||||
"branches": {
|
||||
"release": "main",
|
||||
"dev": "dev"
|
||||
}
|
||||
```
|
||||
|
||||
### Development Build Workflow
|
||||
|
||||
Test builds on the `dev` branch - no tag needed:
|
||||
|
||||
```bash
|
||||
# 1. On dev branch: Update version in .csproj and CHANGELOG.md
|
||||
git checkout dev
|
||||
|
||||
# 2. Commit your changes
|
||||
git add .
|
||||
git commit -m "Prepare v1.0.1 release"
|
||||
|
||||
# 3. Run the release script (no tag needed!)
|
||||
cd src/scripts/Release-ToGitHub
|
||||
.\Release-ToGitHub.ps1
|
||||
|
||||
# Output: DEV BUILD COMPLETE
|
||||
# Creates: release/maksit.uscheduler-1.0.1.zip (local only)
|
||||
```
|
||||
|
||||
### Production Release Workflow
|
||||
|
||||
When ready to publish, merge to `main`, create tag, and run:
|
||||
|
||||
```bash
|
||||
# 1. Merge to main
|
||||
git checkout main
|
||||
git merge dev
|
||||
|
||||
# 2. Create tag (required on main)
|
||||
git tag v1.0.1
|
||||
|
||||
# 3. Run the release script
|
||||
cd src/scripts/Release-ToGitHub
|
||||
.\Release-ToGitHub.ps1
|
||||
|
||||
# Output: RELEASE COMPLETE
|
||||
# Creates: release/maksit.uscheduler-1.0.1.zip
|
||||
# Pushes tag to GitHub
|
||||
# Creates GitHub release with assets
|
||||
```
|
||||
|
||||
### Release Script Details
|
||||
|
||||
The `Release-ToGitHub.ps1` script performs these steps:
|
||||
|
||||
**Pre-flight checks:**
|
||||
- Detects current branch (`main` or `dev`)
|
||||
- On `main`: requires clean working directory; on `dev`: uncommitted changes allowed
|
||||
- Reads version from `.csproj` (source of truth)
|
||||
- On `main`: requires tag matching the version
|
||||
- Ensures `CHANGELOG.md` has matching version entry
|
||||
- Checks GitHub CLI authentication (main branch only)
|
||||
|
||||
**Build process:**
|
||||
- Publishes .NET project in Release configuration
|
||||
- Copies `Scripts` folder into the release
|
||||
- Creates versioned ZIP archive
|
||||
- Extracts release notes from `CHANGELOG.md`
|
||||
|
||||
**GitHub release (main branch only):**
|
||||
- Pushes tag to remote if not present
|
||||
- Creates (or recreates) GitHub release with assets
|
||||
|
||||
**Configuration:**
|
||||
|
||||
The script reads settings from `scriptsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"github": {
|
||||
"tokenEnvVar": "GITHUB_MAKS_IT_COM"
|
||||
},
|
||||
"paths": {
|
||||
"csprojPath": "..\\..\\MaksIT.UScheduler\\MaksIT.UScheduler.csproj",
|
||||
"changelogPath": "..\\..\\..\\CHANGELOG.md",
|
||||
"releaseDir": "..\\..\\release"
|
||||
},
|
||||
"release": {
|
||||
"zipNamePattern": "maksit.uscheduler-{version}.zip",
|
||||
"releaseTitlePattern": "Release {version}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changelog Guidelines
|
||||
|
||||
Follow [Keep a Changelog](https://keepachangelog.com/) format:
|
||||
|
||||
```markdown
|
||||
## v1.0.1
|
||||
|
||||
### Added
|
||||
- New features
|
||||
|
||||
### Changed
|
||||
- Changes to existing functionality
|
||||
|
||||
### Deprecated
|
||||
- Features to be removed in future
|
||||
|
||||
### Removed
|
||||
- Removed features
|
||||
|
||||
### Fixed
|
||||
- Bug fixes
|
||||
|
||||
### Security
|
||||
- Security-related changes
|
||||
```
|
||||
|
||||
---
|
||||
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Maksym Sadovnychyy (Maks-IT)
|
||||
Copyright (c) 2017 - 2026 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
|
||||
|
||||
258
README.md
258
README.md
@ -1,7 +1,7 @@
|
||||
|
||||
|
||||
# MaksIT Unified Scheduler Service
|
||||
|
||||
  
|
||||
|
||||
A modern, fully rewritten Windows service built on **.NET 10** for scheduling and running PowerShell scripts and console applications.
|
||||
Designed for system administrators — and also for those who *feel like* system administrators — who need a predictable, resilient, and secure background execution environment.
|
||||
|
||||
@ -14,38 +14,52 @@ Designed for system administrators — and also for those who *feel like* system
|
||||
- [Scripts Examples](#scripts-examples)
|
||||
- [Features at a Glance](#features-at-a-glance)
|
||||
- [Installation](#installation)
|
||||
- [Recommended (using bundled scripts)](#recommended-using-bundled-scripts)
|
||||
- [Manual Installation](#manual-installation)
|
||||
- [Using CLI Commands](#using-cli-commands)
|
||||
- [Using sc.exe](#using-scexe)
|
||||
- [Configuration (`appsettings.json`)](#configuration-appsettingsjson)
|
||||
- [Path Resolution](#path-resolution)
|
||||
- [Log Levels](#log-levels)
|
||||
- [PowerShell Scripts](#powershell-scripts)
|
||||
- [Processes](#processes)
|
||||
- [How It Works](#how-it-works)
|
||||
- [PowerShell Execution Parameters](#powershell-execution-parameters)
|
||||
- [Thread Layout](#thread-layout)
|
||||
- [Execution Model](#execution-model)
|
||||
- [Reusable Scheduler Module (`SchedulerTemplate.psm1`)](#reusable-scheduler-module-schedulertemplatepsm1)
|
||||
- [Exported Functions](#exported-functions)
|
||||
- [Module Version](#module-version)
|
||||
- [Example usage](#example-usage)
|
||||
- [Security](#security)
|
||||
- [Logging](#logging)
|
||||
- [Testing](#testing)
|
||||
- [Running Tests](#running-tests)
|
||||
- [Code Coverage](#code-coverage)
|
||||
- [Test Structure](#test-structure)
|
||||
- [Contact](#contact)
|
||||
- [License](#license)
|
||||
|
||||
## Scripts Examples
|
||||
- [Hyper-V Backup](./examples/HyperV-Backup/README.md) - Production-ready Hyper-V VM backup solution with scheduling and retention management
|
||||
- [Native-Sync](./examples/Native-Sync/README.md) - Production-ready file synchronization solution using pure PowerShell with no external dependencies
|
||||
- [File-Sync](./examples/File-Sync/README.md) - [FreeFileSync](https://freefilesync.org/) batch job execution
|
||||
- [Scheduler Template Module](./examples/SchedulerTemplate.psm1)
|
||||
|
||||
> **Note:** These examples are **bundled with the release** and included in the default `appsettings.json`, but are **disabled by default**. To enable an example, set `"Disabled": false` in the configuration.
|
||||
|
||||
- [Hyper-V Backup](./src/Scripts/HyperV-Backup/README.md) - Production-ready Hyper-V VM backup solution with scheduling and retention management
|
||||
- [Native-Sync](./src/Scripts/Native-Sync/README.md) - Production-ready file synchronization solution using pure PowerShell with no external dependencies
|
||||
- [File-Sync](./src/Scripts/File-Sync/README.md) - [FreeFileSync](https://freefilesync.org/) batch job execution
|
||||
- [Windows-Update](./src/Scripts/Windows-Update/README.md) - Production-ready Windows Update automation solution using pure PowerShell
|
||||
- [Scheduler Template Module](./src/Scripts/SchedulerTemplate.psm1)
|
||||
|
||||
---
|
||||
|
||||
## Features at a Glance
|
||||
|
||||
* **.NET 10 Worker Service** – clean, robust, stable.
|
||||
* **Windows only** – designed specifically for Windows services.
|
||||
* **Strongly typed configuration** via `appsettings.json`.
|
||||
* **Run PowerShell scripts & executables concurrently** (each in its own thread).
|
||||
* **Parallel execution** – PowerShell scripts & executables run concurrently using RunspacePool and Task.WhenAll.
|
||||
* **Relative path support** – script and process paths can be relative to the application directory.
|
||||
* **Signature enforcement** (AllSigned by default).
|
||||
* **Automatic restart-on-failure** for supervised processes.
|
||||
* **Extensible logging** (file + console).
|
||||
* **Simple Install.cmd / Uninstall.cmd**.
|
||||
* **Extensible logging** (file + console + Windows EventLog).
|
||||
* **Built-in CLI** for service management (`--install`, `--uninstall`, `--start`, `--stop`, `--status`).
|
||||
* **Reusable scheduling module**: `SchedulerTemplate.psm1`.
|
||||
* **Thread-isolated architecture** — individual failures do not affect others.
|
||||
|
||||
@ -53,30 +67,55 @@ Designed for system administrators — and also for those who *feel like* system
|
||||
|
||||
## Installation
|
||||
|
||||
### Recommended (using bundled scripts)
|
||||
### Using CLI Commands
|
||||
|
||||
```bat
|
||||
cd /d path\to\src\MaksIT.UScheduler
|
||||
Install.cmd
|
||||
The executable includes built-in service management commands. Run as Administrator:
|
||||
|
||||
```powershell
|
||||
# Install the service (auto-start enabled)
|
||||
MaksIT.UScheduler.exe --install
|
||||
|
||||
# Start the service
|
||||
MaksIT.UScheduler.exe --start
|
||||
|
||||
# Check service status
|
||||
MaksIT.UScheduler.exe --status
|
||||
|
||||
# Stop the service
|
||||
MaksIT.UScheduler.exe --stop
|
||||
|
||||
# Uninstall the service
|
||||
MaksIT.UScheduler.exe --uninstall
|
||||
|
||||
# Show help
|
||||
MaksIT.UScheduler.exe --help
|
||||
```
|
||||
|
||||
| Command | Short | Description |
|
||||
|---------|-------|-------------|
|
||||
| `--install` | `-i` | Install the Windows service (auto-start) |
|
||||
| `--uninstall` | `-u` | Stop and remove the Windows service |
|
||||
| `--start` | | Start the service |
|
||||
| `--stop` | | Stop the service |
|
||||
| `--status` | | Query service status |
|
||||
| `--help` | `-h` | Show help message |
|
||||
|
||||
> **Note:** Service management commands require administrator privileges.
|
||||
|
||||
### Using sc.exe
|
||||
|
||||
Alternatively, use Windows Service Control Manager directly:
|
||||
|
||||
```powershell
|
||||
sc.exe create "MaksIT.UScheduler" binpath="C:\Path\To\MaksIT.UScheduler.exe" start=auto
|
||||
sc.exe start "MaksIT.UScheduler"
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```bat
|
||||
Uninstall.cmd
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```powershell
|
||||
sc.exe create "MaksIT.UScheduler Service" binpath="C:\Path\To\MaksIT.UScheduler.exe"
|
||||
sc.exe start "MaksIT.UScheduler Service"
|
||||
```
|
||||
|
||||
Manual uninstall:
|
||||
|
||||
```powershell
|
||||
sc.exe delete "MaksIT.UScheduler Service"
|
||||
sc.exe stop "MaksIT.UScheduler"
|
||||
sc.exe delete "MaksIT.UScheduler"
|
||||
```
|
||||
|
||||
---
|
||||
@ -85,31 +124,79 @@ sc.exe delete "MaksIT.UScheduler Service"
|
||||
|
||||
```json
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information"
|
||||
},
|
||||
"EventLog": {
|
||||
"SourceName": "MaksIT.UScheduler",
|
||||
"LogName": "Application",
|
||||
"LogLevel": {
|
||||
"Microsoft": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Configuration": {
|
||||
"ServiceName": "MaksIT.UScheduler",
|
||||
"LogDir": "C:\\Logs",
|
||||
|
||||
"Powershell": [
|
||||
{ "Path": "C:\\Scripts\\MyScript.ps1", "IsSigned": true }
|
||||
{ "Path": "../Scripts/MyScript.ps1", "IsSigned": true, "Disabled": false },
|
||||
{ "Path": "C:\\Scripts\\AnotherScript.ps1", "IsSigned": false, "Disabled": true }
|
||||
],
|
||||
|
||||
"Processes": [
|
||||
{ "Path": "C:\\Programs\\MyApp.exe", "Args": ["--option"], "RestartOnFailure": true }
|
||||
{ "Path": "../Tools/MyApp.exe", "Args": ["--option"], "RestartOnFailure": true, "Disabled": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** `ServiceName` and `LogDir` are optional. Defaults: `"MaksIT.UScheduler"` and `Logs` folder in app directory.
|
||||
|
||||
### Path Resolution
|
||||
|
||||
Paths can be either absolute or relative:
|
||||
|
||||
| Path Type | Example | Resolved To |
|
||||
|-----------|---------|-------------|
|
||||
| Absolute | `C:\Scripts\backup.ps1` | `C:\Scripts\backup.ps1` |
|
||||
| Relative | `../Scripts/backup.ps1` | `{AppDirectory}\..\Scripts\backup.ps1` |
|
||||
| Relative | `scripts/backup.ps1` | `{AppDirectory}\scripts\backup.ps1` |
|
||||
|
||||
Relative paths are resolved against the application's base directory (where `MaksIT.UScheduler.exe` is located).
|
||||
|
||||
### Log Levels
|
||||
|
||||
The `"Default": "Information"` setting controls the minimum severity of messages that get logged. Available levels (from most to least verbose):
|
||||
|
||||
| Level | Description |
|
||||
|-------|-------------|
|
||||
| `Trace` | Most detailed, for debugging internals |
|
||||
| `Debug` | Debugging information |
|
||||
| `Information` | General operational events (recommended default) |
|
||||
| `Warning` | Abnormal or unexpected events |
|
||||
| `Error` | Errors and exceptions |
|
||||
| `Critical` | Critical failures requiring immediate attention |
|
||||
| `None` | Disables logging |
|
||||
|
||||
### PowerShell Scripts
|
||||
|
||||
* `Path` — full `.ps1` file path
|
||||
* `IsSigned` — `true` enforces AllSigned, `false` runs unrestricted
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `Path` | string | required | Path to `.ps1` file (absolute or relative) |
|
||||
| `IsSigned` | bool | `true` | `true` enforces AllSigned, `false` runs unrestricted |
|
||||
| `Disabled` | bool | `false` | `true` skips this script during execution |
|
||||
|
||||
### Processes
|
||||
|
||||
* `Path` — executable
|
||||
* `Args` — command-line arguments
|
||||
* `RestartOnFailure` — restart logic handled by service
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `Path` | string | required | Path to executable (absolute or relative) |
|
||||
| `Args` | string[] | `null` | Command-line arguments |
|
||||
| `RestartOnFailure` | bool | `false` | Restart process if it exits with non-zero code |
|
||||
| `Disabled` | bool | `false` | `true` skips this process during execution |
|
||||
|
||||
---
|
||||
|
||||
@ -133,21 +220,28 @@ param (
|
||||
)
|
||||
```
|
||||
|
||||
### Thread Layout
|
||||
### Execution Model
|
||||
|
||||
Scripts and processes run **in parallel** using:
|
||||
|
||||
- **PowerShell**: `RunspacePool` (up to CPU core count concurrent runspaces)
|
||||
- **Processes**: `Task.WhenAll` for concurrent process execution
|
||||
|
||||
```
|
||||
Unified Scheduler Service
|
||||
├── PowerShell
|
||||
│ ├── ScriptA.ps1 Thread
|
||||
│ ├── ScriptB.ps1 Thread
|
||||
│ └── ...
|
||||
└── Processes
|
||||
├── ProgramA.exe Thread
|
||||
├── ProgramB.exe Thread
|
||||
└── ...
|
||||
├── PSScriptBackgroundService (RunspacePool)
|
||||
│ ├── ScriptA.ps1 ─┐
|
||||
│ ├── ScriptB.ps1 ─┼─ Parallel execution
|
||||
│ └── ScriptC.ps1 ─┘
|
||||
└── ProcessBackgroundService (Task.WhenAll)
|
||||
├── ProgramA.exe ─┐
|
||||
├── ProgramB.exe ─┼─ Parallel execution
|
||||
└── ProgramC.exe ─┘
|
||||
```
|
||||
|
||||
A crash in one thread **never stops the service** or other components.
|
||||
- A failure in one script/process **never stops the service** or other components.
|
||||
- The same script/process won't run twice concurrently (protected by "already running" check).
|
||||
- Execution cycle repeats every 10 seconds.
|
||||
|
||||
---
|
||||
|
||||
@ -164,7 +258,20 @@ This module provides:
|
||||
* Automatic lock file (no concurrent execution)
|
||||
* Last-run file tracking
|
||||
* Unified callback execution pattern
|
||||
* Logging helpers (Write-Log)
|
||||
|
||||
### Exported Functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `Write-Log` | Logging with timestamp, level (Info/Success/Warning/Error), and color support |
|
||||
| `Invoke-ScheduledExecution` | Main scheduler — checks schedule, manages locks, runs callback |
|
||||
| `Get-CredentialFromEnvVar` | Retrieves credentials from Base64-encoded machine environment variables |
|
||||
| `Test-UNCPath` | Validates whether a path is a UNC path |
|
||||
| `Send-EmailNotification` | Sends SMTP email with optional SSL and credential support |
|
||||
|
||||
### Module Version
|
||||
|
||||
The module exports `$ModuleVersion` and `$ModuleDate` for version tracking.
|
||||
|
||||
### Example usage
|
||||
|
||||
@ -205,20 +312,65 @@ That’s it — the full scheduling engine is reused automatically.
|
||||
|
||||
## Security
|
||||
|
||||
* Signed scripts required by default.
|
||||
* Scripts are auto-unblocked before execution.
|
||||
* Unrestricted execution can be enabled if needed (not recommended on production systems).
|
||||
* Scripts run with **AllSigned** execution policy by default.
|
||||
* Set `IsSigned: false` to use **Unrestricted** policy (not recommended for production).
|
||||
* Scripts are auto-unblocked before execution (Zone.Identifier removed).
|
||||
* Signature validation ensures only trusted scripts execute.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
* Console logging
|
||||
* File logging under the directory specified by `LogDir`
|
||||
* **Console logging** — standard output
|
||||
* **File logging** — written to `LogDir` (default: `Logs` folder in app directory)
|
||||
* **Windows EventLog** — events logged to Application log under `MaksIT.UScheduler` source
|
||||
* All events (start, stop, crash, restart, error, skip) are logged
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
The project includes a comprehensive test suite using **xUnit** and **Moq** for unit testing.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```powershell
|
||||
# Run all tests
|
||||
dotnet test src/MaksIT.UScheduler.Tests
|
||||
|
||||
# Run with verbose output
|
||||
dotnet test src/MaksIT.UScheduler.Tests --verbosity normal
|
||||
```
|
||||
|
||||
### Code Coverage
|
||||
|
||||
Coverage badges are generated locally using [ReportGenerator](https://github.com/danielpalme/ReportGenerator).
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
```powershell
|
||||
dotnet tool install --global dotnet-reportgenerator-globaltool
|
||||
```
|
||||
|
||||
**Generate coverage report and badges:**
|
||||
|
||||
```powershell
|
||||
.\src\scripts\Run-Coverage\Run-Coverage.ps1
|
||||
|
||||
# With HTML report opened in browser
|
||||
.\src\scripts\Run-Coverage\Run-Coverage.ps1 -OpenReport
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
| Test Class | Coverage |
|
||||
|------------|----------|
|
||||
| `ConfigurationTests` | Configuration POCOs and default values |
|
||||
| `ProcessBackgroundServiceTests` | Process execution lifecycle and error handling |
|
||||
| `PSScriptBackgroundServiceTests` | PowerShell script execution and signature validation |
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
Maksym Sadovnychyy – MAKS-IT, 2025
|
||||
|
||||
21
badges/coverage-branches.svg
Normal file
21
badges/coverage-branches.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<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>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="147.5" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="107.5" height="20" fill="#555"/>
|
||||
<rect x="107.5" width="40" height="20" fill="#e05d44"/>
|
||||
<rect width="147.5" 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">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>
|
||||
</g>
|
||||
</svg>
|
||||
21
badges/coverage-lines.svg
Normal file
21
badges/coverage-lines.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<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>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="137" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="94.5" height="20" fill="#555"/>
|
||||
<rect x="94.5" width="42.5" height="20" fill="#fe7d37"/>
|
||||
<rect width="137" 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="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>
|
||||
</g>
|
||||
</svg>
|
||||
21
badges/coverage-methods.svg
Normal file
21
badges/coverage-methods.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<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>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="150" height="20" rx="3" fill="#fff"/>
|
||||
</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 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>
|
||||
</g>
|
||||
</svg>
|
||||
8
src/MaksIT.UScheduler.ScheduleManager/App.xaml
Normal file
8
src/MaksIT.UScheduler.ScheduleManager/App.xaml
Normal file
@ -0,0 +1,8 @@
|
||||
<Application x:Class="MaksIT.UScheduler.ScheduleManager.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
7
src/MaksIT.UScheduler.ScheduleManager/App.xaml.cs
Normal file
7
src/MaksIT.UScheduler.ScheduleManager/App.xaml.cs
Normal file
@ -0,0 +1,7 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace MaksIT.UScheduler.ScheduleManager;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
}
|
||||
445
src/MaksIT.UScheduler.ScheduleManager/MainWindow.xaml
Normal file
445
src/MaksIT.UScheduler.ScheduleManager/MainWindow.xaml
Normal file
@ -0,0 +1,445 @@
|
||||
<Window x:Class="MaksIT.UScheduler.ScheduleManager.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:MaksIT.UScheduler.ScheduleManager.ViewModels"
|
||||
xmlns:svc="clr-namespace:MaksIT.UScheduler.ScheduleManager.Services"
|
||||
xmlns:shared="clr-namespace:MaksIT.UScheduler.Shared;assembly=MaksIT.UScheduler.Shared"
|
||||
mc:Ignorable="d"
|
||||
Title="MaksIT.UScheduler Manager"
|
||||
Height="800" Width="1100"
|
||||
MinHeight="600" MinWidth="900"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
|
||||
<Window.DataContext>
|
||||
<vm:MainViewModel />
|
||||
</Window.DataContext>
|
||||
|
||||
<Window.Resources>
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||
|
||||
<Style TargetType="GroupBox">
|
||||
<Setter Property="Margin" Value="5"/>
|
||||
<Setter Property="Padding" Value="5"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Padding" Value="10,5"/>
|
||||
<Setter Property="Margin" Value="3"/>
|
||||
<Setter Property="MinWidth" Value="80"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Margin" Value="5,2"/>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid>
|
||||
<TabControl>
|
||||
<!-- Main Tab -->
|
||||
<TabItem Header="Main">
|
||||
<TabControl>
|
||||
<!-- Scripts Management -->
|
||||
<TabItem Header="Scripts Management">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="280"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Left Panel: Scripts List -->
|
||||
<GroupBox Grid.Column="0" Header="Scripts">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<ListBox Grid.Row="0" ItemsSource="{Binding Scripts}" SelectedItem="{Binding SelectedScript}" DisplayMemberPath="Name"/>
|
||||
<Button Grid.Row="1" Content="Refresh" Command="{Binding RefreshScriptsCommand}" HorizontalAlignment="Stretch" Margin="0,5,0,0"/>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- Right Panel: Schedule Settings & Script Status -->
|
||||
<Grid Grid.Column="1">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Script Status Panel -->
|
||||
<GroupBox Grid.Row="0" Header="Script Status"
|
||||
Visibility="{Binding HasSelectedScript, Converter={StaticResource BoolToVis}}">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal">
|
||||
<TextBlock Text="Last Run: " FontWeight="Bold" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding CurrentScriptStatus.LastRunDisplay, FallbackValue='Never'}"
|
||||
VerticalAlignment="Center" Margin="0,0,30,0"/>
|
||||
|
||||
<TextBlock Text="Status: " FontWeight="Bold" VerticalAlignment="Center"/>
|
||||
<Ellipse Width="10" Height="10" Margin="0,0,5,0" VerticalAlignment="Center">
|
||||
<Ellipse.Style>
|
||||
<Style TargetType="Ellipse">
|
||||
<Setter Property="Fill" Value="LimeGreen"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasLockFile}" Value="True">
|
||||
<Setter Property="Fill" Value="Orange"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Ellipse.Style>
|
||||
</Ellipse>
|
||||
<TextBlock Text="{Binding CurrentScriptStatus.StatusDisplay, FallbackValue='Ready'}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal">
|
||||
<Button Content="Remove Lock" Command="{Binding RemoveLockFileCommand}"
|
||||
Visibility="{Binding HasLockFile, Converter={StaticResource BoolToVis}}"
|
||||
Background="#FFFFCCCC"/>
|
||||
<Button Content="Refresh Status" Command="{Binding RefreshScriptStatusCommand}"/>
|
||||
<Button Content="Launch" Command="{Binding LaunchScriptCommand}"
|
||||
ToolTip="Run script via its .bat file in a separate window"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<TextBlock Text="Name:" VerticalAlignment="Center" Margin="0,0,8,0" FontWeight="Bold"/>
|
||||
<TextBox Text="{Binding ScriptName, UpdateSourceTrigger=PropertyChanged}" MinWidth="180" VerticalAlignment="Center"
|
||||
ToolTip="Display name for this script in the list; saved to service appsettings"/>
|
||||
<CheckBox Content="Require signed script" IsChecked="{Binding ScriptIsSigned, UpdateSourceTrigger=PropertyChanged}" Margin="20,0,0,0"
|
||||
ToolTip="When checked, only scripts with valid Authenticode signatures will be executed"/>
|
||||
<CheckBox Content="Disabled" IsChecked="{Binding ScriptDisabled, UpdateSourceTrigger=PropertyChanged}" Margin="20,0,0,0"
|
||||
ToolTip="When checked, this script will not be executed by the scheduler"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- Schedule Settings -->
|
||||
<GroupBox Grid.Row="1" Header="Schedule Settings">
|
||||
<Grid Visibility="{Binding HasSelectedScript, Converter={StaticResource BoolToVis}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Months Section -->
|
||||
<GroupBox Grid.Row="0" Header="Months (empty = all months)">
|
||||
<WrapPanel>
|
||||
<CheckBox Content="Jan" IsChecked="{Binding MonthOptions[0].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Feb" IsChecked="{Binding MonthOptions[1].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Mar" IsChecked="{Binding MonthOptions[2].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Apr" IsChecked="{Binding MonthOptions[3].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="May" IsChecked="{Binding MonthOptions[4].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Jun" IsChecked="{Binding MonthOptions[5].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Jul" IsChecked="{Binding MonthOptions[6].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Aug" IsChecked="{Binding MonthOptions[7].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Sep" IsChecked="{Binding MonthOptions[8].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Oct" IsChecked="{Binding MonthOptions[9].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Nov" IsChecked="{Binding MonthOptions[10].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Dec" IsChecked="{Binding MonthOptions[11].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
</WrapPanel>
|
||||
</GroupBox>
|
||||
|
||||
<!-- Weekdays Section -->
|
||||
<GroupBox Grid.Row="1" Header="Weekdays (empty = all days)">
|
||||
<WrapPanel>
|
||||
<CheckBox Content="Mon" IsChecked="{Binding WeekdayOptions[0].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Tue" IsChecked="{Binding WeekdayOptions[1].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Wed" IsChecked="{Binding WeekdayOptions[2].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Thu" IsChecked="{Binding WeekdayOptions[3].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Fri" IsChecked="{Binding WeekdayOptions[4].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Sat" IsChecked="{Binding WeekdayOptions[5].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
<CheckBox Content="Sun" IsChecked="{Binding WeekdayOptions[6].IsChecked}"
|
||||
Checked="OnScheduleChanged" Unchecked="OnScheduleChanged"/>
|
||||
</WrapPanel>
|
||||
</GroupBox>
|
||||
|
||||
<!-- Run Times Section -->
|
||||
<GroupBox Grid.Row="2" Header="Run Times (UTC)">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="80"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Add Time Controls -->
|
||||
<StackPanel Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"
|
||||
Orientation="Horizontal" Margin="0,0,0,5">
|
||||
<TextBox Text="{Binding NewRunTime, UpdateSourceTrigger=PropertyChanged}"
|
||||
Width="80" VerticalAlignment="Center"/>
|
||||
<Button Content="Add" Command="{Binding AddRunTimeCommand}" MinWidth="60"/>
|
||||
<Button Content="Remove" Command="{Binding RemoveRunTimeCommand}" MinWidth="60"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Times List -->
|
||||
<ListBox Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2"
|
||||
ItemsSource="{Binding RunTimes}"
|
||||
SelectedItem="{Binding SelectedRunTime}"/>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- Min Interval Section -->
|
||||
<GroupBox Grid.Row="3" Header="Minimum Interval">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
|
||||
<TextBox Text="{Binding MinIntervalMinutes, UpdateSourceTrigger=PropertyChanged}"
|
||||
Width="60" VerticalAlignment="Center"/>
|
||||
<TextBlock Text=" minutes between executions" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<!-- Save/Revert Buttons -->
|
||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<TextBlock Text="* Unsaved changes" Foreground="Orange" VerticalAlignment="Center"
|
||||
Margin="0,0,20,0"
|
||||
Visibility="{Binding IsDirty, Converter={StaticResource BoolToVis}}"/>
|
||||
<Button Content="Revert" Command="{Binding RevertScheduleCommand}" MinWidth="80"/>
|
||||
<Button Content="Save" Command="{Binding SaveScheduleCommand}" MinWidth="80"
|
||||
FontWeight="Bold"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- Processes Management (to-do; hidden for now) -->
|
||||
<TabItem Header="Processes Management" Visibility="Collapsed">
|
||||
<GroupBox Header="Processes">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" Margin="0,0,0,8" TextWrapping="Wrap">
|
||||
<Run Text="Processes are configured in MaksIT.UScheduler appsettings.json (Configuration.Processes). Listed below are the entries currently loaded."/>
|
||||
</TextBlock>
|
||||
<ListBox Grid.Row="1" ItemsSource="{Binding ProcessList}" MinHeight="120">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type shared:ProcessConfiguration}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Path}" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="11" Foreground="Gray">
|
||||
<Run Text="Disabled: "/><Run Text="{Binding Disabled, Mode=OneWay}"/>
|
||||
<Run Text=" Restart on failure: "/><Run Text="{Binding RestartOnFailure, Mode=OneWay}"/>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</TabItem>
|
||||
|
||||
<!-- Logs Tab -->
|
||||
<TabItem Header="Logs">
|
||||
<GroupBox Header="Log Viewer" Margin="5">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="250"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Log Files List -->
|
||||
<Grid Grid.Column="0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TabControl Grid.Row="0" Grid.RowSpan="2" SelectedIndex="{Binding SelectedLogTab}">
|
||||
<TabItem Header="Service Logs">
|
||||
<ListBox ItemsSource="{Binding ServiceLogs}"
|
||||
SelectedItem="{Binding SelectedLogFile}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding SizeDisplay}" FontSize="10" Foreground="Gray"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</TabItem>
|
||||
<TabItem Header="Script Logs">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="120"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Script folders" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="Log files" FontWeight="SemiBold" Margin="5,0,0,4"/>
|
||||
<ListBox Grid.Row="1" Grid.Column="0" ItemsSource="{Binding ScriptLogFolders}"
|
||||
SelectedItem="{Binding SelectedScriptLogFolder}"/>
|
||||
<ListBox Grid.Row="1" Grid.Column="1" Margin="5,0,0,0"
|
||||
ItemsSource="{Binding ScriptLogFiles}"
|
||||
SelectedItem="{Binding SelectedLogFile}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding SizeDisplay}" FontSize="10" Foreground="Gray"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,5,0,0">
|
||||
<Button Content="Refresh" Command="{Binding RefreshServiceLogsCommand}" MinWidth="60" Padding="5,2"/>
|
||||
<Button Content="Open Folder" Command="{Binding OpenLogDirectoryCommand}" MinWidth="70" Padding="5,2"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Log Content -->
|
||||
<Grid Grid.Column="1" Margin="5,0,0,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,5">
|
||||
<TextBlock Text="Log Content" FontWeight="Bold" VerticalAlignment="Center"/>
|
||||
<Button Content="Refresh" Command="{Binding RefreshLogContentCommand}"
|
||||
MinWidth="60" Padding="5,2" Margin="10,0,0,0"/>
|
||||
<Button Content="Open in Explorer" Command="{Binding OpenLogInExplorerCommand}"
|
||||
MinWidth="100" Padding="5,2"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBox Grid.Row="1"
|
||||
Text="{Binding LogContent, Mode=OneWay}"
|
||||
IsReadOnly="True"
|
||||
FontFamily="Consolas"
|
||||
FontSize="11"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
TextWrapping="NoWrap"
|
||||
AcceptsReturn="True"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
</TabItem>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<TabItem Header="Settings">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="5">
|
||||
<GroupBox Header="Service Control">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Margin="0,0,0,8"
|
||||
Text="{Binding AppSettingsLoadError}"
|
||||
Visibility="{Binding AppSettingsLoadErrorVisibility}"
|
||||
Foreground="DarkRed" TextWrapping="Wrap"/>
|
||||
|
||||
<Grid Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="Service Bin Path:" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding ServiceBinPath, UpdateSourceTrigger=PropertyChanged}" VerticalAlignment="Center"/>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Margin="5,0,0,0">
|
||||
<Button Content="..." Command="{Binding BrowseServiceBinPathCommand}" MinWidth="30" Padding="5,2"/>
|
||||
<Button Content="Reload" Command="{Binding ReloadAppSettingsCommand}" MinWidth="50" Padding="5,2"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="Service: " FontWeight="Bold"/>
|
||||
<TextBlock Text="{Binding ServiceName}" Margin="0,0,20,0"/>
|
||||
<TextBlock Text="Status: " FontWeight="Bold"/>
|
||||
<Ellipse Width="12" Height="12" Margin="0,0,5,0" VerticalAlignment="Center">
|
||||
<Ellipse.Style>
|
||||
<Style TargetType="Ellipse">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Green"><Setter Property="Fill" Value="LimeGreen"/></DataTrigger>
|
||||
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Red"><Setter Property="Fill" Value="Red"/></DataTrigger>
|
||||
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Orange"><Setter Property="Fill" Value="Orange"/></DataTrigger>
|
||||
<DataTrigger Binding="{Binding ServiceStatusColor}" Value="Gray"><Setter Property="Fill" Value="Gray"/></DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Ellipse.Style>
|
||||
</Ellipse>
|
||||
<TextBlock Text="{Binding ServiceStatusText}" FontWeight="SemiBold"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2" Grid.Column="2" Orientation="Horizontal">
|
||||
<Button Content="Register" Command="{Binding RegisterServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
|
||||
<Button Content="Unregister" Command="{Binding UnregisterServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
|
||||
<Separator Style="{StaticResource {x:Static ToolBar.SeparatorStyleKey}}" Margin="10,0"/>
|
||||
<Button Content="Start" Command="{Binding StartServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
|
||||
<Button Content="Stop" Command="{Binding StopServiceCommand}" IsEnabled="{Binding IsAdmin}"/>
|
||||
<Separator Style="{StaticResource {x:Static ToolBar.SeparatorStyleKey}}" Margin="10,0"/>
|
||||
<Button Content="Refresh" Command="{Binding RefreshServiceStatusCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox Header="Log Path" Margin="0,10,0,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Log Directory:" VerticalAlignment="Center" Margin="0,0,10,0" FontWeight="Bold"/>
|
||||
<TextBox Text="{Binding LogDirectory, UpdateSourceTrigger=PropertyChanged}" MinWidth="300" VerticalAlignment="Center" IsReadOnly="True" Background="#F0F0F0"/>
|
||||
<Button Content="Open" Command="{Binding OpenLogDirectoryCommand}" Margin="5,0" MinWidth="50"/>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
</Window>
|
||||
20
src/MaksIT.UScheduler.ScheduleManager/MainWindow.xaml.cs
Normal file
20
src/MaksIT.UScheduler.ScheduleManager/MainWindow.xaml.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using System.Windows;
|
||||
using MaksIT.UScheduler.ScheduleManager.ViewModels;
|
||||
|
||||
namespace MaksIT.UScheduler.ScheduleManager;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void OnScheduleChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.OnMonthCheckedChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<Version>1.0.1</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -0,0 +1,59 @@
|
||||
namespace MaksIT.UScheduler.ScheduleManager.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the schedule configuration from a scriptsettings.json file.
|
||||
/// </summary>
|
||||
public class ScriptSchedule
|
||||
{
|
||||
/// <summary>
|
||||
/// Full path to the scriptsettings.json file.
|
||||
/// </summary>
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Display name for the script in the UI (from JSON "name" or "title", or derived from path).
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Script title from the JSON "title" field.
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Array of month names to run (empty = every month).
|
||||
/// Valid values: January, February, March, April, May, June, July, August, September, October, November, December
|
||||
/// </summary>
|
||||
public List<string> RunMonth { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Array of weekday names to run (empty = every day).
|
||||
/// Valid values: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
|
||||
/// </summary>
|
||||
public List<string> RunWeekday { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Array of UTC times in HH:mm format when script should run.
|
||||
/// </summary>
|
||||
public List<string> RunTime { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Minimum minutes between runs to prevent duplicate executions.
|
||||
/// </summary>
|
||||
public int MinIntervalMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Script path as stored in service appsettings (e.g. ".\file-sync.ps1"). Used to find and update the config entry.
|
||||
/// </summary>
|
||||
public string ConfigScriptPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the script must be digitally signed (from service Configuration.Powershell).
|
||||
/// </summary>
|
||||
public bool IsSigned { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the script is disabled and will not be executed (from service Configuration.Powershell).
|
||||
/// </summary>
|
||||
public bool Disabled { get; set; }
|
||||
}
|
||||
49
src/MaksIT.UScheduler.ScheduleManager/Models/ScriptStatus.cs
Normal file
49
src/MaksIT.UScheduler.ScheduleManager/Models/ScriptStatus.cs
Normal file
@ -0,0 +1,49 @@
|
||||
namespace MaksIT.UScheduler.ScheduleManager.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the runtime status of a script (lock file, last run).
|
||||
/// </summary>
|
||||
public class ScriptStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the script's .ps1 file.
|
||||
/// </summary>
|
||||
public string ScriptPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Script name (derived from folder name).
|
||||
/// </summary>
|
||||
public string ScriptName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a lock file exists (script is currently running or was interrupted).
|
||||
/// </summary>
|
||||
public bool HasLockFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the lock file.
|
||||
/// </summary>
|
||||
public string LockFilePath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Last execution time (from .lastRun file).
|
||||
/// </summary>
|
||||
public DateTime? LastRun { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the last run file.
|
||||
/// </summary>
|
||||
public string LastRunFilePath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Formatted last run time for display.
|
||||
/// </summary>
|
||||
public string LastRunDisplay => LastRun.HasValue
|
||||
? LastRun.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")
|
||||
: "Never";
|
||||
|
||||
/// <summary>
|
||||
/// Status indicator for display.
|
||||
/// </summary>
|
||||
public string StatusDisplay => HasLockFile ? "Locked" : "Ready";
|
||||
}
|
||||
@ -0,0 +1,206 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using MaksIT.UScheduler.Shared;
|
||||
using MaksIT.UScheduler.Shared.Helpers;
|
||||
|
||||
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for loading and managing appsettings.json from UScheduler.
|
||||
/// </summary>
|
||||
public class AppSettingsService
|
||||
{
|
||||
private string? _appSettingsPath;
|
||||
private JsonNode? _rootDocument;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the appsettings.json file.
|
||||
/// </summary>
|
||||
public string? AppSettingsPath => _appSettingsPath;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directory containing appsettings.json (the service directory).
|
||||
/// </summary>
|
||||
public string? ServiceDirectory => string.IsNullOrEmpty(_appSettingsPath)
|
||||
? null
|
||||
: System.IO.Path.GetDirectoryName(_appSettingsPath);
|
||||
|
||||
/// <summary>
|
||||
/// Loads appsettings.json from the specified path.
|
||||
/// Returns null if path is null/empty or the file does not exist.
|
||||
/// </summary>
|
||||
public Configuration? LoadAppSettings(string? appSettingsPath = null)
|
||||
{
|
||||
_appSettingsPath = appSettingsPath;
|
||||
|
||||
if (string.IsNullOrEmpty(_appSettingsPath) || !File.Exists(_appSettingsPath))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_appSettingsPath);
|
||||
_rootDocument = JsonNode.Parse(json);
|
||||
|
||||
if (_rootDocument == null)
|
||||
return null;
|
||||
|
||||
var configNode = _rootDocument["Configuration"];
|
||||
if (configNode == null)
|
||||
return new Configuration { LogDir = ".\\Logs" };
|
||||
|
||||
var config = new Configuration
|
||||
{
|
||||
ServiceName = configNode["ServiceName"]?.GetValue<string>() ?? "MaksIT.UScheduler",
|
||||
LogDir = configNode["LogDir"]?.GetValue<string>() ?? ".\\Logs"
|
||||
};
|
||||
|
||||
// Parse PowerShell scripts
|
||||
if (configNode["Powershell"] is JsonArray psArray)
|
||||
{
|
||||
foreach (var item in psArray)
|
||||
{
|
||||
if (item == null) continue;
|
||||
config.Powershell.Add(new PowershellScript
|
||||
{
|
||||
Path = item["Path"]?.GetValue<string>() ?? string.Empty,
|
||||
Name = item["Name"]?.GetValue<string>(),
|
||||
IsSigned = item["IsSigned"]?.GetValue<bool>() ?? true,
|
||||
Disabled = item["Disabled"]?.GetValue<bool>() ?? false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Processes
|
||||
if (configNode["Processes"] is JsonArray procArray)
|
||||
{
|
||||
foreach (var item in procArray)
|
||||
{
|
||||
if (item == null) continue;
|
||||
var proc = new ProcessConfiguration
|
||||
{
|
||||
Path = item["Path"]?.GetValue<string>() ?? string.Empty,
|
||||
RestartOnFailure = item["RestartOnFailure"]?.GetValue<bool>() ?? false,
|
||||
Disabled = item["Disabled"]?.GetValue<bool>() ?? false
|
||||
};
|
||||
|
||||
if (item["Args"] is JsonArray argsArray)
|
||||
{
|
||||
proc.Args = argsArray.Select(a => a?.GetValue<string>() ?? "").ToArray();
|
||||
}
|
||||
|
||||
config.Processes.Add(proc);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved log directory path from the service config.
|
||||
/// LogDir may be relative (e.g. ".\Logs"); it is resolved against the service directory, same as script paths.
|
||||
/// </summary>
|
||||
public string GetLogDirectory(Configuration? config)
|
||||
{
|
||||
if (config == null || string.IsNullOrEmpty(ServiceDirectory))
|
||||
return string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(config.LogDir))
|
||||
return ResolvePathRelativeToService(config.LogDir);
|
||||
|
||||
// Default: Logs folder in service directory
|
||||
return System.IO.Path.Combine(ServiceDirectory, "Logs");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the service executable path.
|
||||
/// </summary>
|
||||
public string GetExecutablePath()
|
||||
{
|
||||
if (string.IsNullOrEmpty(ServiceDirectory))
|
||||
return string.Empty;
|
||||
|
||||
var exePath = System.IO.Path.Combine(ServiceDirectory, "MaksIT.UScheduler.exe");
|
||||
return File.Exists(exePath) ? exePath : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the LogDir value from MaksIT.UScheduler's appsettings.json at the given path.
|
||||
/// The path should be the full path to appsettings.json (e.g. ServiceBinPath + "appsettings.json").
|
||||
/// Returns null if the file doesn't exist or Configuration.LogDir is missing.
|
||||
/// </summary>
|
||||
public static string? GetLogDirFromAppSettingsFile(string appSettingsFilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(appSettingsFilePath) || !File.Exists(appSettingsFilePath))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(appSettingsFilePath);
|
||||
var doc = JsonNode.Parse(json);
|
||||
var configNode = doc?["Configuration"];
|
||||
return configNode?["LogDir"]?.GetValue<string>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates Name, IsSigned and Disabled for a PowerShell script entry in appsettings and saves the file.
|
||||
/// The script is identified by its config path (e.g. ".\file-sync.ps1").
|
||||
/// Pass null for name to leave the existing Name value unchanged.
|
||||
/// </summary>
|
||||
public bool UpdatePowershellScriptEntry(string configScriptPath, string? name, bool isSigned, bool disabled)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_appSettingsPath) || _rootDocument == null)
|
||||
return false;
|
||||
|
||||
var configNode = _rootDocument["Configuration"];
|
||||
if (configNode?["Powershell"] is not JsonArray psArray)
|
||||
return false;
|
||||
|
||||
for (var i = 0; i < psArray.Count; i++)
|
||||
{
|
||||
if (psArray[i] is not JsonObject item)
|
||||
continue;
|
||||
var path = item["Path"]?.GetValue<string>();
|
||||
if (path != configScriptPath)
|
||||
continue;
|
||||
|
||||
if (name != null)
|
||||
item["Name"] = name;
|
||||
item["IsSigned"] = isSigned;
|
||||
item["Disabled"] = disabled;
|
||||
|
||||
try
|
||||
{
|
||||
var options = new JsonWriterOptions { Indented = true };
|
||||
using var stream = File.Create(_appSettingsPath);
|
||||
using var writer = new Utf8JsonWriter(stream, options);
|
||||
_rootDocument.WriteTo(writer);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a path relative to the service directory (where appsettings.json is located).
|
||||
/// If the path is already absolute, returns it as-is.
|
||||
/// </summary>
|
||||
public string ResolvePathRelativeToService(string path) =>
|
||||
PathHelper.ResolvePath(path, ServiceDirectory ?? string.Empty);
|
||||
}
|
||||
@ -0,0 +1,259 @@
|
||||
using System.IO;
|
||||
|
||||
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a log file with metadata.
|
||||
/// </summary>
|
||||
public class LogFileInfo
|
||||
{
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public string FullPath { get; set; } = string.Empty;
|
||||
public DateTime LastModified { get; set; }
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
/// <summary>Script folder name for script logs (e.g. file-sync); empty for service logs.</summary>
|
||||
public string ScriptFolder { get; set; } = string.Empty;
|
||||
|
||||
public string SizeDisplay => SizeBytes switch
|
||||
{
|
||||
< 1024 => $"{SizeBytes} B",
|
||||
< 1024 * 1024 => $"{SizeBytes / 1024.0:F1} KB",
|
||||
_ => $"{SizeBytes / (1024.0 * 1024.0):F1} MB"
|
||||
};
|
||||
|
||||
/// <summary>Display label for list: script folder + file name for script logs, else file name.</summary>
|
||||
public string DisplayLabel => string.IsNullOrEmpty(ScriptFolder) ? FileName : $"{ScriptFolder} \\ {FileName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for viewing log files.
|
||||
/// </summary>
|
||||
public class LogViewerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all log files from the specified log directory.
|
||||
/// </summary>
|
||||
public List<LogFileInfo> GetServiceLogs(string logDir)
|
||||
{
|
||||
var logs = new List<LogFileInfo>();
|
||||
|
||||
if (string.IsNullOrEmpty(logDir) || !Directory.Exists(logDir))
|
||||
return logs;
|
||||
|
||||
try
|
||||
{
|
||||
// Get log files from root directory (service logs)
|
||||
var rootLogs = Directory.GetFiles(logDir, "*.log", SearchOption.TopDirectoryOnly);
|
||||
foreach (var file in rootLogs)
|
||||
{
|
||||
logs.Add(CreateLogFileInfo(file));
|
||||
}
|
||||
|
||||
// Also check for .txt log files
|
||||
var txtLogs = Directory.GetFiles(logDir, "*.txt", SearchOption.TopDirectoryOnly);
|
||||
foreach (var file in txtLogs)
|
||||
{
|
||||
logs.Add(CreateLogFileInfo(file));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore access errors
|
||||
}
|
||||
|
||||
return logs.OrderByDescending(l => l.LastModified).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the names of all script log subfolders under logDir (e.g. file-sync, hyper-v-backup).
|
||||
/// </summary>
|
||||
public List<string> GetScriptLogFolderNames(string logDir)
|
||||
{
|
||||
var names = new List<string>();
|
||||
|
||||
if (string.IsNullOrEmpty(logDir))
|
||||
return names;
|
||||
|
||||
var logDirFull = Path.GetFullPath(logDir);
|
||||
if (!Directory.Exists(logDirFull))
|
||||
return names;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var subDir in Directory.GetDirectories(logDirFull))
|
||||
{
|
||||
var name = Path.GetFileName(subDir);
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
names.Add(name);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore access errors
|
||||
}
|
||||
|
||||
return names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets log files from all script subfolders under logDir (e.g. logs\file-sync, logs\hyper-v-backup).
|
||||
/// Does not require a script to be selected. Each subfolder of logDir is treated as a script log folder.
|
||||
/// </summary>
|
||||
public List<LogFileInfo> GetAllScriptLogs(string logDir)
|
||||
{
|
||||
var logs = new List<LogFileInfo>();
|
||||
|
||||
if (string.IsNullOrEmpty(logDir))
|
||||
return logs;
|
||||
|
||||
var logDirFull = Path.GetFullPath(logDir);
|
||||
if (!Directory.Exists(logDirFull))
|
||||
return logs;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var subDir in Directory.GetDirectories(logDirFull))
|
||||
{
|
||||
var scriptFolder = Path.GetFileName(subDir) ?? string.Empty;
|
||||
var logFiles = Directory.GetFiles(subDir, "*.*", SearchOption.TopDirectoryOnly)
|
||||
.Where(f => f.EndsWith(".log", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var file in logFiles)
|
||||
{
|
||||
var info = CreateLogFileInfo(file);
|
||||
info.ScriptFolder = scriptFolder;
|
||||
logs.Add(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore access errors
|
||||
}
|
||||
|
||||
return logs.OrderByDescending(l => l.LastModified).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets log files for a specific script. Script logs live under LogDir in a subfolder
|
||||
/// named by the script filename without extension (from Configuration.Powershell[].Path).
|
||||
/// E.g. Path=...\file-sync.ps1, LogDir=.\logs → .\logs\file-sync. Tries candidate names, case-insensitive.
|
||||
/// </summary>
|
||||
public List<LogFileInfo> GetScriptLogs(string logDir, string scriptName)
|
||||
{
|
||||
var logs = new List<LogFileInfo>();
|
||||
|
||||
if (string.IsNullOrEmpty(logDir) || string.IsNullOrEmpty(scriptName))
|
||||
return logs;
|
||||
|
||||
var logDirFull = Path.GetFullPath(logDir);
|
||||
if (!Directory.Exists(logDirFull))
|
||||
return logs;
|
||||
|
||||
var scriptLogDir = FindScriptLogSubfolder(logDirFull, scriptName);
|
||||
if (string.IsNullOrEmpty(scriptLogDir))
|
||||
return logs;
|
||||
|
||||
try
|
||||
{
|
||||
var logFiles = Directory.GetFiles(scriptLogDir, "*.*", SearchOption.TopDirectoryOnly)
|
||||
.Where(f => f.EndsWith(".log", StringComparison.OrdinalIgnoreCase) ||
|
||||
f.EndsWith(".txt", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var file in logFiles)
|
||||
{
|
||||
logs.Add(CreateLogFileInfo(file));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore access errors
|
||||
}
|
||||
|
||||
return logs.OrderByDescending(l => l.LastModified).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the script log subfolder under logDir: exact scriptName first, then case-insensitive match.
|
||||
/// </summary>
|
||||
private static string? FindScriptLogSubfolder(string logDir, string scriptName)
|
||||
{
|
||||
var exact = Path.Combine(logDir, scriptName);
|
||||
if (Directory.Exists(exact))
|
||||
return exact;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var subDir in Directory.GetDirectories(logDir))
|
||||
{
|
||||
var name = Path.GetFileName(subDir);
|
||||
if (string.Equals(name, scriptName, StringComparison.OrdinalIgnoreCase))
|
||||
return subDir;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore access errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the content of a log file.
|
||||
/// </summary>
|
||||
public string ReadLogFile(string filePath, int maxLines = 1000)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
return "Log file not found.";
|
||||
|
||||
var lines = File.ReadLines(filePath).TakeLast(maxLines).ToList();
|
||||
|
||||
if (lines.Count == maxLines)
|
||||
{
|
||||
return $"[Showing last {maxLines} lines...]\n\n" + string.Join("\n", lines);
|
||||
}
|
||||
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error reading log file: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the tail of a log file (last N lines).
|
||||
/// </summary>
|
||||
public string ReadLogTail(string filePath, int lines = 100)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
return "Log file not found.";
|
||||
|
||||
var allLines = File.ReadLines(filePath).TakeLast(lines).ToList();
|
||||
return string.Join("\n", allLines);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error reading log file: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private static LogFileInfo CreateLogFileInfo(string filePath)
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
return new LogFileInfo
|
||||
{
|
||||
FileName = fileInfo.Name,
|
||||
FullPath = filePath,
|
||||
LastModified = fileInfo.LastWriteTime,
|
||||
SizeBytes = fileInfo.Length
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using MaksIT.UScheduler.ScheduleManager.Models;
|
||||
|
||||
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for loading and saving scriptsettings.json files.
|
||||
/// </summary>
|
||||
public class ScriptSettingsService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Scans a directory for scriptsettings.json files and loads their schedule configurations.
|
||||
/// </summary>
|
||||
public List<ScriptSchedule> LoadScriptsFromDirectory(string examplesPath)
|
||||
{
|
||||
var schedules = new List<ScriptSchedule>();
|
||||
|
||||
if (!Directory.Exists(examplesPath))
|
||||
return schedules;
|
||||
|
||||
var settingsFiles = Directory.GetFiles(examplesPath, "scriptsettings.json", SearchOption.AllDirectories);
|
||||
|
||||
foreach (var filePath in settingsFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var schedule = LoadScheduleFromFile(filePath);
|
||||
if (schedule != null)
|
||||
schedules.Add(schedule);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip files that can't be parsed
|
||||
}
|
||||
}
|
||||
|
||||
return schedules;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the schedule configuration from a single scriptsettings.json file.
|
||||
/// </summary>
|
||||
public ScriptSchedule? LoadScheduleFromFile(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
return null;
|
||||
|
||||
var json = File.ReadAllText(filePath);
|
||||
var doc = JsonNode.Parse(json);
|
||||
|
||||
if (doc == null)
|
||||
return null;
|
||||
|
||||
var title = doc["title"]?.GetValue<string>() ?? Path.GetDirectoryName(filePath) ?? "Unknown";
|
||||
var name = doc["name"]?.GetValue<string>();
|
||||
// Use name from JSON, or title with " Script Settings" suffix stripped for listbox display
|
||||
var displayName = !string.IsNullOrWhiteSpace(name)
|
||||
? name
|
||||
: StripScriptSettingsSuffix(title);
|
||||
var schedule = new ScriptSchedule
|
||||
{
|
||||
FilePath = filePath,
|
||||
Name = displayName,
|
||||
Title = title
|
||||
};
|
||||
|
||||
var scheduleNode = doc["schedule"];
|
||||
if (scheduleNode != null)
|
||||
{
|
||||
schedule.RunMonth = GetStringArray(scheduleNode["runMonth"]);
|
||||
schedule.RunWeekday = GetStringArray(scheduleNode["runWeekday"]);
|
||||
schedule.RunTime = GetStringArray(scheduleNode["runTime"]);
|
||||
schedule.MinIntervalMinutes = scheduleNode["minIntervalMinutes"]?.GetValue<int>() ?? 10;
|
||||
}
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves only the schedule section back to the scriptsettings.json file,
|
||||
/// preserving all other content.
|
||||
/// </summary>
|
||||
public void SaveScheduleToFile(ScriptSchedule schedule)
|
||||
{
|
||||
if (!File.Exists(schedule.FilePath))
|
||||
throw new FileNotFoundException("Script settings file not found.", schedule.FilePath);
|
||||
|
||||
var json = File.ReadAllText(schedule.FilePath);
|
||||
var doc = JsonNode.Parse(json);
|
||||
|
||||
if (doc == null)
|
||||
throw new InvalidOperationException("Failed to parse JSON file.");
|
||||
|
||||
// Update or create the schedule section
|
||||
var scheduleNode = new JsonObject
|
||||
{
|
||||
["runMonth"] = new JsonArray(schedule.RunMonth.Select(m => JsonValue.Create(m)).ToArray()),
|
||||
["runWeekday"] = new JsonArray(schedule.RunWeekday.Select(d => JsonValue.Create(d)).ToArray()),
|
||||
["runTime"] = new JsonArray(schedule.RunTime.Select(t => JsonValue.Create(t)).ToArray()),
|
||||
["minIntervalMinutes"] = schedule.MinIntervalMinutes
|
||||
};
|
||||
|
||||
doc["schedule"] = scheduleNode;
|
||||
|
||||
// Write back with proper formatting
|
||||
var options = new JsonWriterOptions { Indented = true };
|
||||
using var stream = File.Create(schedule.FilePath);
|
||||
using var writer = new Utf8JsonWriter(stream, options);
|
||||
doc.WriteTo(writer);
|
||||
}
|
||||
|
||||
/// <summary>Strips " Script Settings" (case-insensitive) from the end of a title for listbox display.</summary>
|
||||
private static string StripScriptSettingsSuffix(string title)
|
||||
{
|
||||
const string suffix = " Script Settings";
|
||||
if (title.Length > suffix.Length && title.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
return title[..^suffix.Length].TrimEnd();
|
||||
return title;
|
||||
}
|
||||
|
||||
private static List<string> GetStringArray(JsonNode? node)
|
||||
{
|
||||
if (node is not JsonArray array)
|
||||
return [];
|
||||
|
||||
return array
|
||||
.Where(item => item != null)
|
||||
.Select(item => item!.GetValue<string>())
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
using System.IO;
|
||||
using MaksIT.UScheduler.ScheduleManager.Models;
|
||||
|
||||
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing script status files (lock files, last run).
|
||||
/// </summary>
|
||||
public class ScriptStatusService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the status of a script from its scriptsettings.json path.
|
||||
/// </summary>
|
||||
public ScriptStatus GetScriptStatus(string scriptSettingsPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(scriptSettingsPath) ?? string.Empty;
|
||||
var scriptName = Path.GetFileName(directory);
|
||||
|
||||
// Find the .ps1 file in the same directory
|
||||
var scriptFiles = Directory.GetFiles(directory, "*.ps1", SearchOption.TopDirectoryOnly);
|
||||
var scriptPath = scriptFiles.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(scriptPath))
|
||||
{
|
||||
return new ScriptStatus
|
||||
{
|
||||
ScriptPath = string.Empty,
|
||||
ScriptName = scriptName,
|
||||
HasLockFile = false,
|
||||
LastRun = null
|
||||
};
|
||||
}
|
||||
|
||||
var lockFilePath = Path.ChangeExtension(scriptPath, ".lock");
|
||||
var lastRunFilePath = Path.ChangeExtension(scriptPath, ".lastRun");
|
||||
|
||||
return new ScriptStatus
|
||||
{
|
||||
ScriptPath = scriptPath,
|
||||
ScriptName = scriptName,
|
||||
HasLockFile = File.Exists(lockFilePath),
|
||||
LockFilePath = lockFilePath,
|
||||
LastRun = GetLastRunTime(lastRunFilePath),
|
||||
LastRunFilePath = lastRunFilePath
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the lock file for a script.
|
||||
/// </summary>
|
||||
public bool RemoveLockFile(string lockFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(lockFilePath))
|
||||
{
|
||||
File.Delete(lockFilePath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the last run time from the .lastRun file.
|
||||
/// </summary>
|
||||
private static DateTime? GetLastRunTime(string lastRunFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(lastRunFilePath))
|
||||
return null;
|
||||
|
||||
var content = File.ReadAllText(lastRunFilePath).Trim();
|
||||
if (DateTime.TryParse(
|
||||
content,
|
||||
null,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal,
|
||||
out var lastRun))
|
||||
{
|
||||
return lastRun;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using MaksIT.UScheduler.Shared.Helpers;
|
||||
|
||||
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// UI-specific settings stored in the ScheduleManager's appsettings.json.
|
||||
/// </summary>
|
||||
public class UISettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the MaksIT.UScheduler bin folder containing appsettings.json and MaksIT.UScheduler.exe.
|
||||
/// </summary>
|
||||
public string ServiceBinPath { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for loading and saving the ScheduleManager's own appsettings.json.
|
||||
/// </summary>
|
||||
public class UISettingsService
|
||||
{
|
||||
private readonly string _settingsFilePath;
|
||||
|
||||
public UISettingsService()
|
||||
{
|
||||
_settingsFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "appsettings.json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the UI settings from appsettings.json.
|
||||
/// </summary>
|
||||
public UISettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_settingsFilePath))
|
||||
return new UISettings();
|
||||
|
||||
var json = File.ReadAllText(_settingsFilePath);
|
||||
var doc = JsonNode.Parse(json);
|
||||
|
||||
if (doc == null)
|
||||
return new UISettings();
|
||||
|
||||
var settingsNode = doc["USchedulerSettings"];
|
||||
if (settingsNode == null)
|
||||
return new UISettings();
|
||||
|
||||
return new UISettings
|
||||
{
|
||||
ServiceBinPath = settingsNode["ServiceBinPath"]?.GetValue<string>() ?? string.Empty
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new UISettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the UI settings to appsettings.json.
|
||||
/// </summary>
|
||||
public void Save(UISettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
JsonNode? doc;
|
||||
|
||||
// Load existing file or create new
|
||||
if (File.Exists(_settingsFilePath))
|
||||
{
|
||||
var json = File.ReadAllText(_settingsFilePath);
|
||||
doc = JsonNode.Parse(json) ?? new JsonObject();
|
||||
}
|
||||
else
|
||||
{
|
||||
doc = new JsonObject();
|
||||
}
|
||||
|
||||
// Update the USchedulerSettings section
|
||||
doc["USchedulerSettings"] = new JsonObject
|
||||
{
|
||||
["ServiceBinPath"] = settings.ServiceBinPath
|
||||
};
|
||||
|
||||
// Write back
|
||||
var options = new JsonWriterOptions { Indented = true };
|
||||
using var stream = File.Create(_settingsFilePath);
|
||||
using var writer = new Utf8JsonWriter(stream, options);
|
||||
doc.WriteTo(writer);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore save errors
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the UI's appsettings.json file.
|
||||
/// </summary>
|
||||
public string SettingsFilePath => _settingsFilePath;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a path relative to the application's base directory.
|
||||
/// If the path is already absolute, returns it as-is.
|
||||
/// </summary>
|
||||
public static string ResolvePath(string path) => PathHelper.ResolvePath(path);
|
||||
}
|
||||
@ -0,0 +1,239 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MaksIT.UScheduler.ScheduleManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service status enumeration.
|
||||
/// </summary>
|
||||
public enum ServiceStatus
|
||||
{
|
||||
NotInstalled,
|
||||
Stopped,
|
||||
StartPending,
|
||||
StopPending,
|
||||
Running,
|
||||
ContinuePending,
|
||||
PausePending,
|
||||
Paused,
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a service operation.
|
||||
/// </summary>
|
||||
public record ServiceOperationResult(bool Success, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Manages Windows service operations for MaksIT.UScheduler by calling the executable with CLI args.
|
||||
/// </summary>
|
||||
public class WindowsServiceManager
|
||||
{
|
||||
private const string DefaultServiceName = "MaksIT.UScheduler";
|
||||
|
||||
public string ServiceName { get; }
|
||||
|
||||
public WindowsServiceManager(string? serviceName = null)
|
||||
{
|
||||
ServiceName = serviceName ?? DefaultServiceName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current status of the service by running --status command.
|
||||
/// </summary>
|
||||
public ServiceStatus GetStatus(string? executablePath = null)
|
||||
{
|
||||
// Try using sc.exe query directly for status
|
||||
var result = RunScCommand($"query \"{ServiceName}\"");
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
// Service might not exist
|
||||
if (result.Message.Contains("1060") || result.Message.Contains("does not exist"))
|
||||
return ServiceStatus.NotInstalled;
|
||||
|
||||
return ServiceStatus.Unknown;
|
||||
}
|
||||
|
||||
// Parse state from sc.exe output. Use numeric state code (locale-independent);
|
||||
// sc query shows e.g. "STATE : 4 RUNNING" (text may be localized).
|
||||
var match = Regex.Match(result.Message, @"STATE\s*:\s*(\d+)", RegexOptions.IgnoreCase);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var state))
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
1 => ServiceStatus.Stopped,
|
||||
2 => ServiceStatus.StartPending,
|
||||
3 => ServiceStatus.StopPending,
|
||||
4 => ServiceStatus.Running,
|
||||
5 => ServiceStatus.ContinuePending,
|
||||
6 => ServiceStatus.PausePending,
|
||||
7 => ServiceStatus.Paused,
|
||||
_ => ServiceStatus.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: text match (English)
|
||||
var output = result.Message.ToUpperInvariant();
|
||||
if (output.Contains("RUNNING")) return ServiceStatus.Running;
|
||||
if (output.Contains("STOPPED")) return ServiceStatus.Stopped;
|
||||
if (output.Contains("START_PENDING")) return ServiceStatus.StartPending;
|
||||
if (output.Contains("STOP_PENDING")) return ServiceStatus.StopPending;
|
||||
if (output.Contains("PAUSED")) return ServiceStatus.Paused;
|
||||
if (output.Contains("PAUSE_PENDING")) return ServiceStatus.PausePending;
|
||||
if (output.Contains("CONTINUE_PENDING")) return ServiceStatus.ContinuePending;
|
||||
|
||||
return ServiceStatus.Unknown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the service using the executable's --start command.
|
||||
/// </summary>
|
||||
public ServiceOperationResult Start(string? executablePath = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(executablePath) && File.Exists(executablePath))
|
||||
{
|
||||
return RunExecutable(executablePath, "--start");
|
||||
}
|
||||
|
||||
// Fallback to sc.exe
|
||||
return RunScCommand($"start \"{ServiceName}\"");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the service using the executable's --stop command.
|
||||
/// </summary>
|
||||
public ServiceOperationResult Stop(string? executablePath = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(executablePath) && File.Exists(executablePath))
|
||||
{
|
||||
return RunExecutable(executablePath, "--stop");
|
||||
}
|
||||
|
||||
// Fallback to sc.exe
|
||||
return RunScCommand($"stop \"{ServiceName}\"");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers (installs) the service using the executable's --install command.
|
||||
/// </summary>
|
||||
public ServiceOperationResult Register(string executablePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(executablePath))
|
||||
return new ServiceOperationResult(false, "Executable path is required.");
|
||||
|
||||
if (!File.Exists(executablePath))
|
||||
return new ServiceOperationResult(false, $"Executable not found: {executablePath}");
|
||||
|
||||
var status = GetStatus();
|
||||
if (status != ServiceStatus.NotInstalled)
|
||||
return new ServiceOperationResult(false, "Service is already installed.");
|
||||
|
||||
return RunExecutable(executablePath, "--install");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters (removes) the service using the executable's --uninstall command.
|
||||
/// </summary>
|
||||
public ServiceOperationResult Unregister(string? executablePath = null)
|
||||
{
|
||||
var status = GetStatus();
|
||||
|
||||
if (status == ServiceStatus.NotInstalled)
|
||||
return new ServiceOperationResult(false, "Service is not installed.");
|
||||
|
||||
if (!string.IsNullOrEmpty(executablePath) && File.Exists(executablePath))
|
||||
{
|
||||
return RunExecutable(executablePath, "--uninstall");
|
||||
}
|
||||
|
||||
// Fallback: stop first, then delete via sc.exe
|
||||
if (status == ServiceStatus.Running)
|
||||
{
|
||||
RunScCommand($"stop \"{ServiceName}\"");
|
||||
}
|
||||
|
||||
return RunScCommand($"delete \"{ServiceName}\"");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the UScheduler executable with the specified arguments.
|
||||
/// </summary>
|
||||
private static ServiceOperationResult RunExecutable(string executablePath, string arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executablePath,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(executablePath) ?? ""
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null)
|
||||
return new ServiceOperationResult(false, "Failed to start process.");
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit(30000); // 30 second timeout
|
||||
|
||||
var message = string.IsNullOrEmpty(error) ? output.Trim() : $"{output}\n{error}".Trim();
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
return new ServiceOperationResult(true, message);
|
||||
|
||||
return new ServiceOperationResult(false, message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ServiceOperationResult(false, $"Failed to execute: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs an sc.exe command and returns the result.
|
||||
/// </summary>
|
||||
private static ServiceOperationResult RunScCommand(string arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "sc.exe",
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null)
|
||||
return new ServiceOperationResult(false, "Failed to start sc.exe process.");
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
// sc.exe often writes to stderr; combine both so status parsing sees the STATE line
|
||||
var message = $"{output}{error}".Trim();
|
||||
if (string.IsNullOrEmpty(message))
|
||||
message = process.ExitCode == 0 ? "OK" : "Command failed.";
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
return new ServiceOperationResult(true, message);
|
||||
|
||||
return new ServiceOperationResult(false, message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ServiceOperationResult(false, $"Failed to execute sc.exe: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,953 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using MaksIT.UScheduler.ScheduleManager.Models;
|
||||
using MaksIT.UScheduler.ScheduleManager.Services;
|
||||
using MaksIT.UScheduler.Shared;
|
||||
using MaksIT.UScheduler.Shared.Helpers;
|
||||
|
||||
namespace MaksIT.UScheduler.ScheduleManager.ViewModels;
|
||||
|
||||
public partial class MainViewModel : ObservableObject
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the current process is running as administrator.
|
||||
/// </summary>
|
||||
public bool IsAdmin
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
|
||||
var principal = new System.Security.Principal.WindowsPrincipal(identity);
|
||||
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
private readonly ScriptSettingsService _scriptSettingsService;
|
||||
private readonly UISettingsService _uiSettingsService;
|
||||
private readonly AppSettingsService _appSettingsService;
|
||||
private readonly ScriptStatusService _scriptStatusService;
|
||||
private readonly LogViewerService _logViewerService;
|
||||
private WindowsServiceManager _serviceManager;
|
||||
private Configuration? _serviceConfig;
|
||||
private ScriptSchedule? _originalSchedule;
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
_scriptSettingsService = new ScriptSettingsService();
|
||||
_uiSettingsService = new UISettingsService();
|
||||
_appSettingsService = new AppSettingsService();
|
||||
_scriptStatusService = new ScriptStatusService();
|
||||
_logViewerService = new LogViewerService();
|
||||
_serviceManager = new WindowsServiceManager();
|
||||
|
||||
// Initialize month/weekday options
|
||||
MonthOptions = [
|
||||
new MonthOption("January"), new MonthOption("February"), new MonthOption("March"),
|
||||
new MonthOption("April"), new MonthOption("May"), new MonthOption("June"),
|
||||
new MonthOption("July"), new MonthOption("August"), new MonthOption("September"),
|
||||
new MonthOption("October"), new MonthOption("November"), new MonthOption("December")
|
||||
];
|
||||
|
||||
WeekdayOptions = [
|
||||
new WeekdayOption("Monday"), new WeekdayOption("Tuesday"), new WeekdayOption("Wednesday"),
|
||||
new WeekdayOption("Thursday"), new WeekdayOption("Friday"), new WeekdayOption("Saturday"),
|
||||
new WeekdayOption("Sunday")
|
||||
];
|
||||
|
||||
// Load UI settings first (stored paths)
|
||||
LoadUISettings();
|
||||
|
||||
// Initial load
|
||||
RefreshScripts();
|
||||
RefreshServiceStatus();
|
||||
RefreshServiceLogs();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads UI settings from the ScheduleManager's appsettings.json.
|
||||
/// If paths are empty, tries auto-detection.
|
||||
/// </summary>
|
||||
private void LoadUISettings()
|
||||
{
|
||||
var uiSettings = _uiSettingsService.Load();
|
||||
|
||||
// Use stored bin path - no auto-detection, user will configure manually if empty
|
||||
ServiceBinPath = uiSettings.ServiceBinPath ?? string.Empty;
|
||||
|
||||
// Load the UScheduler's appsettings.json to get service config
|
||||
LoadServiceAppSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved service bin path (absolute path).
|
||||
/// </summary>
|
||||
private string ResolvedServiceBinPath => UISettingsService.ResolvePath(ServiceBinPath);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved appsettings.json path from the service bin folder.
|
||||
/// </summary>
|
||||
private string ResolvedAppSettingsPath => string.IsNullOrEmpty(ResolvedServiceBinPath)
|
||||
? string.Empty
|
||||
: Path.Combine(ResolvedServiceBinPath, "appsettings.json");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved executable path from the service bin folder.
|
||||
/// </summary>
|
||||
private string ResolvedExecutablePath => string.IsNullOrEmpty(ResolvedServiceBinPath)
|
||||
? string.Empty
|
||||
: Path.Combine(ResolvedServiceBinPath, "MaksIT.UScheduler.exe");
|
||||
|
||||
/// <summary>
|
||||
/// Loads the UScheduler's appsettings.json from the path in ScheduleManager appsettings (ServiceBinPath).
|
||||
/// LogDir is read from that file and evaluated against the UScheduler program path (ServiceBinPath).
|
||||
/// </summary>
|
||||
private void LoadServiceAppSettings()
|
||||
{
|
||||
// Path to UScheduler appsettings = path from ScheduleManager appsettings (ServiceBinPath) + appsettings.json
|
||||
var uschedulerAppSettingsPath = ResolvedAppSettingsPath;
|
||||
|
||||
if (!string.IsNullOrEmpty(uschedulerAppSettingsPath) && File.Exists(uschedulerAppSettingsPath))
|
||||
{
|
||||
_serviceConfig = _appSettingsService.LoadAppSettings(uschedulerAppSettingsPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_serviceConfig = null;
|
||||
}
|
||||
|
||||
if (_serviceConfig != null)
|
||||
{
|
||||
AppSettingsLoadError = null;
|
||||
// Update service manager with correct service name
|
||||
_serviceManager = new WindowsServiceManager(_serviceConfig.ServiceName);
|
||||
ServiceName = _serviceConfig.ServiceName;
|
||||
// Log dir: retrieve from MaksIT.UScheduler appsettings.json, show relative path in UI (e.g. ".\Logs")
|
||||
// Configuration.LogDir is required, so it is always set when _serviceConfig is loaded.
|
||||
LogDirectory = AppSettingsService.GetLogDirFromAppSettingsFile(uschedulerAppSettingsPath)
|
||||
?? _serviceConfig.LogDir;
|
||||
// Refresh process list for Processes Management tab
|
||||
ProcessList.Clear();
|
||||
foreach (var p in _serviceConfig.Processes)
|
||||
ProcessList.Add(p);
|
||||
}
|
||||
else
|
||||
{
|
||||
// appsettings.json not loaded — surface error; do not use fake defaults
|
||||
AppSettingsLoadError = string.IsNullOrEmpty(uschedulerAppSettingsPath) || !File.Exists(uschedulerAppSettingsPath)
|
||||
? "Service Bin Path must be set and must point to a folder that contains appsettings.json."
|
||||
: "Failed to load appsettings.json. Ensure the file is valid and contains required settings (e.g. LogDir).";
|
||||
ServiceName = string.Empty;
|
||||
LogDirectory = string.Empty;
|
||||
ProcessList.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the current paths to the UI's appsettings.json.
|
||||
/// </summary>
|
||||
private void SaveUISettings()
|
||||
{
|
||||
var settings = new UISettings
|
||||
{
|
||||
ServiceBinPath = ServiceBinPath
|
||||
};
|
||||
_uiSettingsService.Save(settings);
|
||||
}
|
||||
|
||||
#region AppSettings
|
||||
|
||||
[ObservableProperty]
|
||||
private string _serviceBinPath = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _serviceName = "MaksIT.UScheduler";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AppSettingsLoadErrorVisibility))]
|
||||
private string? _appSettingsLoadError;
|
||||
|
||||
/// <summary>
|
||||
/// Visibility for the app settings load error message (Visible when <see cref="AppSettingsLoadError"/> is set).
|
||||
/// </summary>
|
||||
public Visibility AppSettingsLoadErrorVisibility =>
|
||||
string.IsNullOrEmpty(AppSettingsLoadError) ? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
[RelayCommand]
|
||||
private void BrowseServiceBinPath()
|
||||
{
|
||||
var dialog = new Microsoft.Win32.OpenFolderDialog
|
||||
{
|
||||
Title = "Select MaksIT.UScheduler bin folder"
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
ServiceBinPath = dialog.FolderName;
|
||||
SaveUISettings();
|
||||
LoadServiceAppSettings();
|
||||
RefreshScripts();
|
||||
RefreshServiceStatus();
|
||||
RefreshServiceLogs();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ReloadAppSettings()
|
||||
{
|
||||
LoadServiceAppSettings();
|
||||
RefreshScripts();
|
||||
RefreshServiceStatus();
|
||||
RefreshServiceLogs();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SavePaths()
|
||||
{
|
||||
SaveUISettings();
|
||||
MessageBox.Show("Paths saved successfully.", "Save", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Service Management
|
||||
|
||||
[ObservableProperty]
|
||||
private ServiceStatus _serviceStatus = ServiceStatus.Unknown;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _serviceStatusText = "Unknown";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _serviceStatusColor = "Gray";
|
||||
|
||||
[RelayCommand]
|
||||
private void RefreshServiceStatus()
|
||||
{
|
||||
ServiceStatus = _serviceManager.GetStatus(ResolvedExecutablePath);
|
||||
(ServiceStatusText, ServiceStatusColor) = GetServiceStatusDisplay(ServiceStatus);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void StartService()
|
||||
{
|
||||
var result = _serviceManager.Start(ResolvedExecutablePath);
|
||||
if (!result.Success)
|
||||
{
|
||||
MessageBox.Show(result.Message, "Start Service", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
RefreshServiceStatus();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void StopService()
|
||||
{
|
||||
var result = _serviceManager.Stop(ResolvedExecutablePath);
|
||||
if (!result.Success)
|
||||
{
|
||||
MessageBox.Show(result.Message, "Stop Service", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
RefreshServiceStatus();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RegisterService()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ServiceBinPath))
|
||||
{
|
||||
MessageBox.Show("Please specify the path to MaksIT.UScheduler bin folder", "Register Service",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = _serviceManager.Register(ResolvedExecutablePath);
|
||||
MessageBox.Show(result.Message, "Register Service",
|
||||
MessageBoxButton.OK, result.Success ? MessageBoxImage.Information : MessageBoxImage.Warning);
|
||||
RefreshServiceStatus();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void UnregisterService()
|
||||
{
|
||||
var confirm = MessageBox.Show(
|
||||
"Are you sure you want to unregister the service?",
|
||||
"Unregister Service",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Question);
|
||||
|
||||
if (confirm != MessageBoxResult.Yes)
|
||||
return;
|
||||
|
||||
var result = _serviceManager.Unregister(ResolvedExecutablePath);
|
||||
MessageBox.Show(result.Message, "Unregister Service",
|
||||
MessageBoxButton.OK, result.Success ? MessageBoxImage.Information : MessageBoxImage.Warning);
|
||||
RefreshServiceStatus();
|
||||
}
|
||||
|
||||
private static (string statusText, string statusColor) GetServiceStatusDisplay(ServiceStatus status)
|
||||
{
|
||||
return status switch
|
||||
{
|
||||
ServiceStatus.Running => ("Running", "Green"),
|
||||
ServiceStatus.Stopped => ("Stopped", "Red"),
|
||||
ServiceStatus.StartPending => ("Starting...", "Orange"),
|
||||
ServiceStatus.StopPending => ("Stopping...", "Orange"),
|
||||
ServiceStatus.Paused => ("Paused", "Orange"),
|
||||
ServiceStatus.NotInstalled => ("Not Installed", "Gray"),
|
||||
_ => ("Unknown", "Gray")
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Schedule Management
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<ScriptSchedule> _scripts = [];
|
||||
|
||||
/// <summary>
|
||||
/// Process entries from service config (for Processes Management tab).
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<ProcessConfiguration> _processList = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private ScriptSchedule? _selectedScript;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasSelectedScript;
|
||||
|
||||
/// <summary>Require script to be digitally signed (from service config).</summary>
|
||||
[ObservableProperty]
|
||||
private bool _scriptIsSigned = true;
|
||||
|
||||
/// <summary>Script disabled and will not be executed (from service config).</summary>
|
||||
[ObservableProperty]
|
||||
private bool _scriptDisabled;
|
||||
|
||||
/// <summary>Display name for the selected script (from service config; editable).</summary>
|
||||
[ObservableProperty]
|
||||
private string? _scriptName;
|
||||
|
||||
private bool _isSyncingSelection;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isDirty;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<MonthOption> _monthOptions = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<WeekdayOption> _weekdayOptions = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<string> _runTimes = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _selectedRunTime;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _newRunTime = "00:00";
|
||||
|
||||
[ObservableProperty]
|
||||
private int _minIntervalMinutes = 10;
|
||||
|
||||
partial void OnSelectedScriptChanged(ScriptSchedule? value)
|
||||
{
|
||||
HasSelectedScript = value != null;
|
||||
LaunchScriptCommand.NotifyCanExecuteChanged();
|
||||
|
||||
_isSyncingSelection = true;
|
||||
ScriptIsSigned = value?.IsSigned ?? true;
|
||||
ScriptDisabled = value?.Disabled ?? false;
|
||||
ScriptName = value?.Name ?? string.Empty;
|
||||
_isSyncingSelection = false;
|
||||
|
||||
if (value != null)
|
||||
{
|
||||
LoadScheduleToUI(value);
|
||||
_originalSchedule = CloneSchedule(value);
|
||||
RefreshScriptStatus();
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearUI();
|
||||
_originalSchedule = null;
|
||||
CurrentScriptStatus = null;
|
||||
}
|
||||
|
||||
IsDirty = false;
|
||||
}
|
||||
|
||||
partial void OnScriptIsSignedChanged(bool value)
|
||||
{
|
||||
if (_isSyncingSelection || SelectedScript == null)
|
||||
return;
|
||||
SelectedScript.IsSigned = value;
|
||||
UpdateServiceConfigScriptEntry(SelectedScript.ConfigScriptPath, SelectedScript.Name, value, SelectedScript.Disabled);
|
||||
}
|
||||
|
||||
partial void OnScriptDisabledChanged(bool value)
|
||||
{
|
||||
if (_isSyncingSelection || SelectedScript == null)
|
||||
return;
|
||||
SelectedScript.Disabled = value;
|
||||
UpdateServiceConfigScriptEntry(SelectedScript.ConfigScriptPath, SelectedScript.Name, SelectedScript.IsSigned, value);
|
||||
}
|
||||
|
||||
partial void OnScriptNameChanged(string? value)
|
||||
{
|
||||
if (_isSyncingSelection || SelectedScript == null)
|
||||
return;
|
||||
var name = value ?? string.Empty;
|
||||
SelectedScript.Name = name;
|
||||
UpdateServiceConfigScriptEntry(SelectedScript.ConfigScriptPath, name, SelectedScript.IsSigned, SelectedScript.Disabled);
|
||||
}
|
||||
|
||||
private void UpdateServiceConfigScriptEntry(string configScriptPath, string? name, bool isSigned, bool disabled)
|
||||
{
|
||||
if (_serviceConfig == null)
|
||||
return;
|
||||
var entry = _serviceConfig.Powershell.FirstOrDefault(p => p.Path == configScriptPath);
|
||||
if (entry != null)
|
||||
{
|
||||
if (name != null)
|
||||
entry.Name = name;
|
||||
entry.IsSigned = isSigned;
|
||||
entry.Disabled = disabled;
|
||||
}
|
||||
_appSettingsService.UpdatePowershellScriptEntry(configScriptPath, name, isSigned, disabled);
|
||||
}
|
||||
|
||||
partial void OnMinIntervalMinutesChanged(int value)
|
||||
{
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RefreshScripts()
|
||||
{
|
||||
Scripts.Clear();
|
||||
|
||||
// Load scripts from service configuration (MaksIT.UScheduler appsettings.json)
|
||||
if (_serviceConfig != null)
|
||||
{
|
||||
foreach (var psConfig in _serviceConfig.Powershell)
|
||||
{
|
||||
if (string.IsNullOrEmpty(psConfig.Path))
|
||||
continue;
|
||||
|
||||
// Resolve relative path against service directory
|
||||
var resolvedScriptPath = _appSettingsService.ResolvePathRelativeToService(psConfig.Path);
|
||||
var scriptDir = Path.GetDirectoryName(resolvedScriptPath);
|
||||
|
||||
if (string.IsNullOrEmpty(scriptDir))
|
||||
continue;
|
||||
|
||||
// scriptsettings.json should be in the same folder as the script
|
||||
var settingsPath = Path.Combine(scriptDir, "scriptsettings.json");
|
||||
|
||||
if (File.Exists(settingsPath))
|
||||
{
|
||||
var schedule = _scriptSettingsService.LoadScheduleFromFile(settingsPath);
|
||||
if (schedule != null)
|
||||
{
|
||||
schedule.ConfigScriptPath = psConfig.Path;
|
||||
if (!string.IsNullOrWhiteSpace(psConfig.Name))
|
||||
schedule.Name = psConfig.Name;
|
||||
schedule.IsSigned = psConfig.IsSigned;
|
||||
schedule.Disabled = psConfig.Disabled;
|
||||
Scripts.Add(schedule);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launches the selected script via its .bat file in a separate window.
|
||||
/// </summary>
|
||||
[RelayCommand(CanExecute = nameof(CanLaunchScript))]
|
||||
private void LaunchScript()
|
||||
{
|
||||
if (SelectedScript == null)
|
||||
return;
|
||||
|
||||
var scriptDir = Path.GetDirectoryName(SelectedScript.FilePath);
|
||||
if (string.IsNullOrEmpty(scriptDir) || !Directory.Exists(scriptDir))
|
||||
{
|
||||
MessageBox.Show("Script directory not found.", "Launch Script",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var batFiles = Directory.GetFiles(scriptDir, "*.bat");
|
||||
if (batFiles.Length == 0)
|
||||
{
|
||||
MessageBox.Show($"No .bat file found in script folder.\n{scriptDir}", "Launch Script",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var batPath = batFiles[0];
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = batPath,
|
||||
WorkingDirectory = scriptDir,
|
||||
UseShellExecute = true,
|
||||
CreateNoWindow = false
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Failed to launch script: {ex.Message}", "Launch Script",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanLaunchScript() => SelectedScript != null;
|
||||
|
||||
[RelayCommand]
|
||||
private void AddRunTime()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewRunTime))
|
||||
return;
|
||||
|
||||
// Validate time format
|
||||
if (!TimeOnly.TryParse(NewRunTime, out var time))
|
||||
{
|
||||
MessageBox.Show("Invalid time format. Please use HH:mm format.", "Invalid Time",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var formattedTime = time.ToString("HH:mm");
|
||||
|
||||
if (!RunTimes.Contains(formattedTime))
|
||||
{
|
||||
RunTimes.Add(formattedTime);
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
NewRunTime = "00:00";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveRunTime()
|
||||
{
|
||||
if (SelectedRunTime != null)
|
||||
{
|
||||
RunTimes.Remove(SelectedRunTime);
|
||||
MarkDirty();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveSchedule()
|
||||
{
|
||||
if (SelectedScript == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// Update the selected script from UI
|
||||
UpdateScheduleFromUI(SelectedScript);
|
||||
|
||||
// Save to file
|
||||
_scriptSettingsService.SaveScheduleToFile(SelectedScript);
|
||||
|
||||
// Update original for dirty tracking
|
||||
_originalSchedule = CloneSchedule(SelectedScript);
|
||||
IsDirty = false;
|
||||
|
||||
MessageBox.Show("Schedule saved successfully.", "Save",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Failed to save schedule: {ex.Message}", "Save Error",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RevertSchedule()
|
||||
{
|
||||
if (_originalSchedule != null && SelectedScript != null)
|
||||
{
|
||||
// Copy original values back
|
||||
SelectedScript.RunMonth = new List<string>(_originalSchedule.RunMonth);
|
||||
SelectedScript.RunWeekday = new List<string>(_originalSchedule.RunWeekday);
|
||||
SelectedScript.RunTime = new List<string>(_originalSchedule.RunTime);
|
||||
SelectedScript.MinIntervalMinutes = _originalSchedule.MinIntervalMinutes;
|
||||
|
||||
LoadScheduleToUI(SelectedScript);
|
||||
IsDirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnMonthCheckedChanged()
|
||||
{
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
public void OnWeekdayCheckedChanged()
|
||||
{
|
||||
MarkDirty();
|
||||
}
|
||||
|
||||
private void LoadScheduleToUI(ScriptSchedule schedule)
|
||||
{
|
||||
// Load months
|
||||
foreach (var month in MonthOptions)
|
||||
{
|
||||
month.IsChecked = schedule.RunMonth.Contains(month.Name);
|
||||
}
|
||||
|
||||
// Load weekdays
|
||||
foreach (var weekday in WeekdayOptions)
|
||||
{
|
||||
weekday.IsChecked = schedule.RunWeekday.Contains(weekday.Name);
|
||||
}
|
||||
|
||||
// Load times
|
||||
RunTimes = new ObservableCollection<string>(schedule.RunTime);
|
||||
|
||||
// Load interval
|
||||
MinIntervalMinutes = schedule.MinIntervalMinutes;
|
||||
}
|
||||
|
||||
private void UpdateScheduleFromUI(ScriptSchedule schedule)
|
||||
{
|
||||
schedule.RunMonth = MonthOptions.Where(m => m.IsChecked).Select(m => m.Name).ToList();
|
||||
schedule.RunWeekday = WeekdayOptions.Where(w => w.IsChecked).Select(w => w.Name).ToList();
|
||||
schedule.RunTime = RunTimes.ToList();
|
||||
schedule.MinIntervalMinutes = MinIntervalMinutes;
|
||||
}
|
||||
|
||||
private void ClearUI()
|
||||
{
|
||||
foreach (var month in MonthOptions) month.IsChecked = false;
|
||||
foreach (var weekday in WeekdayOptions) weekday.IsChecked = false;
|
||||
RunTimes.Clear();
|
||||
MinIntervalMinutes = 10;
|
||||
}
|
||||
|
||||
private void MarkDirty()
|
||||
{
|
||||
if (SelectedScript != null)
|
||||
{
|
||||
IsDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static ScriptSchedule CloneSchedule(ScriptSchedule source)
|
||||
{
|
||||
return new ScriptSchedule
|
||||
{
|
||||
FilePath = source.FilePath,
|
||||
Name = source.Name,
|
||||
Title = source.Title,
|
||||
ConfigScriptPath = source.ConfigScriptPath,
|
||||
IsSigned = source.IsSigned,
|
||||
Disabled = source.Disabled,
|
||||
RunMonth = new List<string>(source.RunMonth),
|
||||
RunWeekday = new List<string>(source.RunWeekday),
|
||||
RunTime = new List<string>(source.RunTime),
|
||||
MinIntervalMinutes = source.MinIntervalMinutes
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Script Status (Lock File, Last Run)
|
||||
|
||||
[ObservableProperty]
|
||||
private ScriptStatus? _currentScriptStatus;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _hasLockFile;
|
||||
|
||||
[RelayCommand]
|
||||
private void RefreshScriptStatus()
|
||||
{
|
||||
if (SelectedScript == null)
|
||||
{
|
||||
CurrentScriptStatus = null;
|
||||
HasLockFile = false;
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentScriptStatus = _scriptStatusService.GetScriptStatus(SelectedScript.FilePath);
|
||||
HasLockFile = CurrentScriptStatus?.HasLockFile ?? false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RemoveLockFile()
|
||||
{
|
||||
if (CurrentScriptStatus == null || !CurrentScriptStatus.HasLockFile)
|
||||
{
|
||||
MessageBox.Show("No lock file exists.", "Remove Lock File",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var confirm = MessageBox.Show(
|
||||
"Are you sure you want to remove the lock file?\n\n" +
|
||||
"This should only be done if the script crashed or was interrupted.\n" +
|
||||
"Removing the lock while the script is running may cause issues.",
|
||||
"Remove Lock File",
|
||||
MessageBoxButton.YesNo,
|
||||
MessageBoxImage.Warning);
|
||||
|
||||
if (confirm != MessageBoxResult.Yes)
|
||||
return;
|
||||
|
||||
if (_scriptStatusService.RemoveLockFile(CurrentScriptStatus.LockFilePath))
|
||||
{
|
||||
MessageBox.Show("Lock file removed successfully.", "Remove Lock File",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
RefreshScriptStatus();
|
||||
}
|
||||
else
|
||||
{
|
||||
MessageBox.Show("Failed to remove lock file.", "Remove Lock File",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Log Viewer
|
||||
|
||||
[ObservableProperty]
|
||||
private string _logDirectory = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<LogFileInfo> _serviceLogs = [];
|
||||
|
||||
/// <summary>Script log folder names (e.g. file-sync, hyper-v-backup) under LogDir.</summary>
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<string> _scriptLogFolders = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _selectedScriptLogFolder;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<LogFileInfo> _scriptLogFiles = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private LogFileInfo? _selectedLogFile;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _logContent = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private int _selectedLogTab;
|
||||
|
||||
partial void OnSelectedScriptLogFolderChanged(string? value)
|
||||
{
|
||||
ScriptLogFiles.Clear();
|
||||
SelectedLogFile = null;
|
||||
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return;
|
||||
|
||||
var resolvedLogDir = ResolvedLogDirectory;
|
||||
if (string.IsNullOrEmpty(resolvedLogDir))
|
||||
return;
|
||||
|
||||
var logs = _logViewerService.GetScriptLogs(resolvedLogDir, value);
|
||||
foreach (var log in logs)
|
||||
{
|
||||
ScriptLogFiles.Add(log);
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedLogFileChanged(LogFileInfo? value)
|
||||
{
|
||||
if (value != null)
|
||||
{
|
||||
LoadLogContent(value.FullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogContent = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void BrowseLogDirectory()
|
||||
{
|
||||
var dialog = new Microsoft.Win32.OpenFolderDialog
|
||||
{
|
||||
Title = "Select Log Directory"
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
LogDirectory = dialog.FolderName;
|
||||
RefreshServiceLogs();
|
||||
RefreshScriptLogs();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the resolved log directory path for reading logs. LogDir (e.g. .\logs) is always resolved
|
||||
/// against ServiceBinPath so the result is e.g. ..\..\..\..\MaksIT.UScheduler\bin\Debug\net10.0\win-x64\logs.
|
||||
/// </summary>
|
||||
private string ResolvedLogDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrEmpty(ResolvedServiceBinPath))
|
||||
return string.Empty;
|
||||
// Configuration.LogDir is required; fallback only when config is not loaded.
|
||||
var logDir = _serviceConfig != null ? _serviceConfig.LogDir : ".\\logs";
|
||||
return PathHelper.ResolvePath(logDir, ResolvedServiceBinPath);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RefreshServiceLogs()
|
||||
{
|
||||
ServiceLogs.Clear();
|
||||
|
||||
var resolvedLogDir = ResolvedLogDirectory;
|
||||
if (string.IsNullOrEmpty(resolvedLogDir))
|
||||
return;
|
||||
|
||||
var logs = _logViewerService.GetServiceLogs(resolvedLogDir);
|
||||
foreach (var log in logs)
|
||||
{
|
||||
ServiceLogs.Add(log);
|
||||
}
|
||||
|
||||
// Also refresh script logs so the Script Logs tab stays in sync
|
||||
RefreshScriptLogs();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RefreshScriptLogs()
|
||||
{
|
||||
ScriptLogFolders.Clear();
|
||||
SelectedScriptLogFolder = null;
|
||||
ScriptLogFiles.Clear();
|
||||
SelectedLogFile = null;
|
||||
|
||||
var resolvedLogDir = ResolvedLogDirectory;
|
||||
if (string.IsNullOrEmpty(resolvedLogDir))
|
||||
return;
|
||||
|
||||
var folderNames = _logViewerService.GetScriptLogFolderNames(resolvedLogDir);
|
||||
foreach (var name in folderNames)
|
||||
{
|
||||
ScriptLogFolders.Add(name);
|
||||
}
|
||||
|
||||
if (ScriptLogFolders.Count > 0)
|
||||
{
|
||||
SelectedScriptLogFolder = ScriptLogFolders[0];
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RefreshLogContent()
|
||||
{
|
||||
if (SelectedLogFile != null)
|
||||
{
|
||||
LoadLogContent(SelectedLogFile.FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenLogInExplorer()
|
||||
{
|
||||
if (SelectedLogFile == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{SelectedLogFile.FullPath}\"");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Failed to open explorer: {ex.Message}", "Error",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenLogDirectory()
|
||||
{
|
||||
var resolvedLogDir = ResolvedLogDirectory;
|
||||
if (string.IsNullOrEmpty(resolvedLogDir) || !Directory.Exists(resolvedLogDir))
|
||||
{
|
||||
MessageBox.Show("Log directory not found.", "Error",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start("explorer.exe", resolvedLogDir);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"Failed to open explorer: {ex.Message}", "Error",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadLogContent(string filePath)
|
||||
{
|
||||
LogContent = _logViewerService.ReadLogFile(filePath);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a month checkbox option.
|
||||
/// </summary>
|
||||
public partial class MonthOption : ObservableObject
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isChecked;
|
||||
|
||||
public MonthOption(string name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a weekday checkbox option.
|
||||
/// </summary>
|
||||
public partial class WeekdayOption : ObservableObject
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isChecked;
|
||||
|
||||
public WeekdayOption(string name)
|
||||
{
|
||||
Name = name;
|
||||
}
|
||||
}
|
||||
19
src/MaksIT.UScheduler.ScheduleManager/app.manifest
Normal file
19
src/MaksIT.UScheduler.ScheduleManager/app.manifest
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="MaksIT.UScheduler.ScheduleManager"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<!-- UAC Manifest Options
|
||||
If you want to change the Windows User Account Control level replace the
|
||||
requestedExecutionLevel node with one of the following.
|
||||
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
|
||||
-->
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
5
src/MaksIT.UScheduler.ScheduleManager/appsettings.json
Normal file
5
src/MaksIT.UScheduler.ScheduleManager/appsettings.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"USchedulerSettings": {
|
||||
"ServiceBinPath": "..\\..\\..\\..\\MaksIT.UScheduler\\bin\\Debug\\net10.0\\win-x64"
|
||||
}
|
||||
}
|
||||
78
src/MaksIT.UScheduler.Shared/Configuration.cs
Normal file
78
src/MaksIT.UScheduler.Shared/Configuration.cs
Normal file
@ -0,0 +1,78 @@
|
||||
namespace MaksIT.UScheduler.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Base configuration class for scheduled tasks.
|
||||
/// </summary>
|
||||
public abstract class TaskConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the path to the executable or script.
|
||||
/// </summary>
|
||||
public required string Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this task is disabled. Disabled tasks will not be executed.
|
||||
/// </summary>
|
||||
public bool Disabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a scheduled PowerShell script.
|
||||
/// </summary>
|
||||
public class PowershellScript : TaskConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the display name for the script in the UI.
|
||||
/// When set in appsettings, overrides the name from scriptsettings.json.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the script must be digitally signed.
|
||||
/// When true, only scripts with valid Authenticode signatures will be executed.
|
||||
/// </summary>
|
||||
public bool IsSigned { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a scheduled process/executable.
|
||||
/// </summary>
|
||||
public class ProcessConfiguration : TaskConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the command-line arguments to pass to the process.
|
||||
/// </summary>
|
||||
public string[]? Args { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the process should automatically restart on failure.
|
||||
/// </summary>
|
||||
public bool RestartOnFailure { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration class for the UScheduler service.
|
||||
/// </summary>
|
||||
public class Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Windows service name used for registration.
|
||||
/// </summary>
|
||||
public string ServiceName { get; set; } = "MaksIT.UScheduler";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the directory path for log files (required).
|
||||
/// May be relative to the application base directory (e.g. ".\Logs") or absolute.
|
||||
/// </summary>
|
||||
public required string LogDir { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of PowerShell scripts to execute.
|
||||
/// </summary>
|
||||
public List<PowershellScript> Powershell { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of processes/executables to run.
|
||||
/// </summary>
|
||||
public List<ProcessConfiguration> Processes { get; set; } = [];
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
using MaksIT.Core.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MaksIT.UScheduler.Shared.Extensions;
|
||||
|
||||
public static class LoggerFactoryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a logger that logs to a dedicated subfolder based on the file name.
|
||||
/// Uses the Folder: prefix pattern from MaksIT.Core.Logging.FileLoggerProvider.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="filePath">The file path to extract the folder name from.</param>
|
||||
/// <returns>An ILogger instance configured for folder-based logging.</returns>
|
||||
public static ILogger CreateFolderLogger(this ILoggerFactory loggerFactory, string filePath)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(filePath);
|
||||
return loggerFactory.CreateLogger($"{LoggerPrefix.Folder}{fileName}");
|
||||
}
|
||||
}
|
||||
37
src/MaksIT.UScheduler.Shared/Helpers/PathHelper.cs
Normal file
37
src/MaksIT.UScheduler.Shared/Helpers/PathHelper.cs
Normal file
@ -0,0 +1,37 @@
|
||||
namespace MaksIT.UScheduler.Shared.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves relative and absolute paths. Relative paths are resolved against a base directory;
|
||||
/// absolute (rooted) paths are returned unchanged.
|
||||
/// </summary>
|
||||
public static class PathHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a path against the application base directory.
|
||||
/// If the path is null, empty, or already rooted (absolute), returns it unchanged.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to resolve.</param>
|
||||
/// <returns>The fully resolved absolute path, or the original value if rooted/null/empty.</returns>
|
||||
public static string ResolvePath(string path)
|
||||
{
|
||||
return ResolvePath(path, AppDomain.CurrentDomain.BaseDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a path against the specified base directory.
|
||||
/// If the path is null, empty, or already rooted (absolute), returns it unchanged.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to resolve.</param>
|
||||
/// <param name="baseDirectory">The base directory for relative path resolution.</param>
|
||||
/// <returns>The fully resolved absolute path, or the original path if rooted/null/empty or base is null/empty.</returns>
|
||||
public static string ResolvePath(string path, string baseDirectory)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) || Path.IsPathRooted(path))
|
||||
return path;
|
||||
|
||||
if (string.IsNullOrEmpty(baseDirectory))
|
||||
return path;
|
||||
|
||||
return Path.GetFullPath(Path.Combine(baseDirectory, path));
|
||||
}
|
||||
}
|
||||
15
src/MaksIT.UScheduler.Shared/MaksIT.UScheduler.Shared.csproj
Normal file
15
src/MaksIT.UScheduler.Shared/MaksIT.UScheduler.Shared.csproj
Normal file
@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>MaksIT.UScheduler.Shared</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -0,0 +1,210 @@
|
||||
using MaksIT.UScheduler;
|
||||
using MaksIT.UScheduler.BackgroundServices;
|
||||
using MaksIT.UScheduler.Services;
|
||||
using MaksIT.UScheduler.Shared;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace MaksIT.UScheduler.Tests.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PSScriptBackgroundService.
|
||||
/// </summary>
|
||||
public class PSScriptBackgroundServiceTests {
|
||||
private readonly Mock<ILogger<PSScriptBackgroundService>> _loggerMock;
|
||||
private readonly Mock<IPSScriptService> _psScriptServiceMock;
|
||||
|
||||
public PSScriptBackgroundServiceTests() {
|
||||
_loggerMock = new Mock<ILogger<PSScriptBackgroundService>>();
|
||||
_psScriptServiceMock = new Mock<IPSScriptService>();
|
||||
}
|
||||
|
||||
private PSScriptBackgroundService CreateService(Configuration config) {
|
||||
var options = Options.Create(config);
|
||||
return new PSScriptBackgroundService(_loggerMock.Object, options, _psScriptServiceMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithNoScripts_DoesNotCallRunScriptAsync() {
|
||||
// Arrange
|
||||
var config = new Configuration { LogDir = ".\\Logs", Powershell = [] };
|
||||
var service = CreateService(config);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
cts.Cancel();
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
_psScriptServiceMock.Verify(
|
||||
x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithDisabledScript_DoesNotRunDisabledScript() {
|
||||
// Arrange
|
||||
var config = new Configuration {
|
||||
LogDir = ".\\Logs",
|
||||
Powershell = [
|
||||
new PowershellScript { Path = @"C:\Scripts\test.ps1", Disabled = true }
|
||||
]
|
||||
};
|
||||
var service = CreateService(config);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
cts.Cancel();
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
_psScriptServiceMock.Verify(
|
||||
x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithEmptyPath_DoesNotRunScript() {
|
||||
// Arrange
|
||||
var config = new Configuration {
|
||||
LogDir = ".\\Logs",
|
||||
Powershell = [ new PowershellScript { Path = "" } ]
|
||||
};
|
||||
var service = CreateService(config);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
cts.Cancel();
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
_psScriptServiceMock.Verify(
|
||||
x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_TerminatesAllScripts() {
|
||||
// Arrange
|
||||
var config = new Configuration { LogDir = ".\\Logs" };
|
||||
var service = CreateService(config);
|
||||
|
||||
// Act
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_psScriptServiceMock.Verify(x => x.TerminateAllScripts(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithEnabledScript_CallsRunScriptAsync() {
|
||||
// Arrange
|
||||
var config = new Configuration {
|
||||
LogDir = ".\\Logs",
|
||||
Powershell = [
|
||||
new PowershellScript { Path = @"C:\Scripts\test.ps1", Disabled = false, IsSigned = true }
|
||||
]
|
||||
};
|
||||
var service = CreateService(config);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
_psScriptServiceMock
|
||||
.Setup(x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var executeTask = service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
|
||||
try {
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Assert
|
||||
_psScriptServiceMock.Verify(
|
||||
x => x.RunScriptAsync(@"C:\Scripts\test.ps1", true, It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithUnsignedScript_PassesIsSignedFalse() {
|
||||
// Arrange
|
||||
var config = new Configuration {
|
||||
LogDir = ".\\Logs",
|
||||
Powershell = [
|
||||
new PowershellScript { Path = @"C:\Scripts\test.ps1", Disabled = false, IsSigned = false }
|
||||
]
|
||||
};
|
||||
var service = CreateService(config);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
_psScriptServiceMock
|
||||
.Setup(x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var executeTask = service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
|
||||
try {
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Assert - verify IsSigned=false was passed
|
||||
_psScriptServiceMock.Verify(
|
||||
x => x.RunScriptAsync(@"C:\Scripts\test.ps1", false, It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithMultipleScripts_RunsAllEnabledScripts() {
|
||||
// Arrange
|
||||
var config = new Configuration {
|
||||
LogDir = ".\\Logs",
|
||||
Powershell = [
|
||||
new PowershellScript { Path = @"C:\Scripts\script1.ps1", Disabled = false },
|
||||
new PowershellScript { Path = @"C:\Scripts\script2.ps1", Disabled = true },
|
||||
new PowershellScript { Path = @"C:\Scripts\script3.ps1", Disabled = false }
|
||||
]
|
||||
};
|
||||
var service = CreateService(config);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
_psScriptServiceMock
|
||||
.Setup(x => x.RunScriptAsync(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var executeTask = service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
|
||||
try {
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Assert - only enabled scripts should run
|
||||
_psScriptServiceMock.Verify(
|
||||
x => x.RunScriptAsync(@"C:\Scripts\script1.ps1", It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
_psScriptServiceMock.Verify(
|
||||
x => x.RunScriptAsync(@"C:\Scripts\script2.ps1", It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
_psScriptServiceMock.Verify(
|
||||
x => x.RunScriptAsync(@"C:\Scripts\script3.ps1", It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,179 @@
|
||||
using MaksIT.UScheduler;
|
||||
using MaksIT.UScheduler.BackgroundServices;
|
||||
using MaksIT.UScheduler.Services;
|
||||
using MaksIT.UScheduler.Shared;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace MaksIT.UScheduler.Tests.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ProcessBackgroundService.
|
||||
/// </summary>
|
||||
public class ProcessBackgroundServiceTests {
|
||||
private readonly Mock<ILogger<ProcessBackgroundService>> _loggerMock;
|
||||
private readonly Mock<IProcessService> _processServiceMock;
|
||||
|
||||
public ProcessBackgroundServiceTests() {
|
||||
_loggerMock = new Mock<ILogger<ProcessBackgroundService>>();
|
||||
_processServiceMock = new Mock<IProcessService>();
|
||||
}
|
||||
|
||||
private ProcessBackgroundService CreateService(Configuration config) {
|
||||
var options = Options.Create(config);
|
||||
return new ProcessBackgroundService(_loggerMock.Object, options, _processServiceMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithNoProcesses_DoesNotCallRunProcessAsync() {
|
||||
// Arrange
|
||||
var config = new Configuration { LogDir = ".\\Logs", Processes = [] };
|
||||
var service = CreateService(config);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act - cancel immediately to stop the service
|
||||
cts.Cancel();
|
||||
|
||||
// Start the service - it should complete without errors
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Assert
|
||||
_processServiceMock.Verify(
|
||||
x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithDisabledProcess_DoesNotRunDisabledProcess() {
|
||||
// Arrange
|
||||
var config = new Configuration {
|
||||
LogDir = ".\\Logs",
|
||||
Processes = [
|
||||
new ProcessConfiguration { Path = @"C:\app.exe", Disabled = true }
|
||||
]
|
||||
};
|
||||
var service = CreateService(config);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
cts.Cancel();
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Assert - disabled processes should not be run
|
||||
_processServiceMock.Verify(
|
||||
x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithEmptyPath_DoesNotRunProcess() {
|
||||
// Arrange
|
||||
var config = new Configuration {
|
||||
LogDir = ".\\Logs",
|
||||
Processes = [ new ProcessConfiguration { Path = "" } ]
|
||||
};
|
||||
var service = CreateService(config);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Act
|
||||
cts.Cancel();
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Assert - processes with empty paths should not be run
|
||||
_processServiceMock.Verify(
|
||||
x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopAsync_TerminatesAllProcesses() {
|
||||
// Arrange
|
||||
var config = new Configuration { LogDir = ".\\Logs" };
|
||||
var service = CreateService(config);
|
||||
|
||||
// Act
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_processServiceMock.Verify(x => x.TerminateAllProcesses(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithEnabledProcess_CallsRunProcessAsync() {
|
||||
// Arrange
|
||||
var config = new Configuration {
|
||||
LogDir = ".\\Logs",
|
||||
Processes = [
|
||||
new ProcessConfiguration { Path = @"C:\app.exe", Disabled = false }
|
||||
]
|
||||
};
|
||||
var service = CreateService(config);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
// Setup mock to complete immediately
|
||||
_processServiceMock
|
||||
.Setup(x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act - start the service then cancel after a short delay
|
||||
var executeTask = service.StartAsync(cts.Token);
|
||||
|
||||
// Give it a moment to start processing, then cancel
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
|
||||
// Wait for the service to stop gracefully
|
||||
try {
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
// Expected when cancelling
|
||||
}
|
||||
|
||||
// Assert - the enabled process should have been run at least once
|
||||
_processServiceMock.Verify(
|
||||
x => x.RunProcessAsync(@"C:\app.exe", It.IsAny<string[]?>(), It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithProcessArgs_PassesArgsToService() {
|
||||
// Arrange
|
||||
var expectedArgs = new[] { "--verbose", "--log" };
|
||||
var config = new Configuration {
|
||||
LogDir = ".\\Logs",
|
||||
Processes = [
|
||||
new ProcessConfiguration {
|
||||
Path = @"C:\app.exe",
|
||||
Disabled = false,
|
||||
Args = expectedArgs
|
||||
}
|
||||
]
|
||||
};
|
||||
var service = CreateService(config);
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
_processServiceMock
|
||||
.Setup(x => x.RunProcessAsync(It.IsAny<string>(), It.IsAny<string[]?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var executeTask = service.StartAsync(cts.Token);
|
||||
await Task.Delay(100);
|
||||
cts.Cancel();
|
||||
|
||||
try {
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Assert - verify args were passed correctly
|
||||
_processServiceMock.Verify(
|
||||
x => x.RunProcessAsync(@"C:\app.exe", expectedArgs, It.IsAny<CancellationToken>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
}
|
||||
155
src/MaksIT.UScheduler.Tests/ConfigurationTests.cs
Normal file
155
src/MaksIT.UScheduler.Tests/ConfigurationTests.cs
Normal file
@ -0,0 +1,155 @@
|
||||
using MaksIT.UScheduler.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace MaksIT.UScheduler.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for configuration classes.
|
||||
/// </summary>
|
||||
public class ConfigurationTests {
|
||||
private const string TestLogDir = ".\\Logs";
|
||||
|
||||
[Fact]
|
||||
public void Configuration_DefaultValues_AreCorrect() {
|
||||
// Arrange & Act
|
||||
var config = new Configuration { LogDir = TestLogDir };
|
||||
|
||||
// Assert
|
||||
Assert.Equal("MaksIT.UScheduler", config.ServiceName);
|
||||
Assert.Equal(TestLogDir, config.LogDir);
|
||||
Assert.NotNull(config.Powershell);
|
||||
Assert.Empty(config.Powershell);
|
||||
Assert.NotNull(config.Processes);
|
||||
Assert.Empty(config.Processes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configuration_CanSetServiceName() {
|
||||
// Arrange
|
||||
var config = new Configuration { LogDir = TestLogDir };
|
||||
|
||||
// Act
|
||||
config.ServiceName = "CustomServiceName";
|
||||
|
||||
// Assert
|
||||
Assert.Equal("CustomServiceName", config.ServiceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configuration_CanSetLogDir() {
|
||||
// Arrange
|
||||
var config = new Configuration { LogDir = TestLogDir };
|
||||
|
||||
// Act
|
||||
config.LogDir = @"C:\Logs";
|
||||
|
||||
// Assert
|
||||
Assert.Equal(@"C:\Logs", config.LogDir);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configuration_CanAddPowerShellScripts() {
|
||||
// Arrange
|
||||
var config = new Configuration { LogDir = TestLogDir };
|
||||
var script = new PowershellScript { Path = @"C:\Scripts\test.ps1" };
|
||||
|
||||
// Act
|
||||
config.Powershell.Add(script);
|
||||
|
||||
// Assert
|
||||
Assert.Single(config.Powershell);
|
||||
Assert.Equal(@"C:\Scripts\test.ps1", config.Powershell[0].Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configuration_CanAddProcesses() {
|
||||
// Arrange
|
||||
var config = new Configuration { LogDir = TestLogDir };
|
||||
var process = new ProcessConfiguration {
|
||||
Path = @"C:\App\app.exe",
|
||||
Args = ["--verbose", "--output", "log.txt"]
|
||||
};
|
||||
|
||||
// Act
|
||||
config.Processes.Add(process);
|
||||
|
||||
// Assert
|
||||
Assert.Single(config.Processes);
|
||||
Assert.Equal(@"C:\App\app.exe", config.Processes[0].Path);
|
||||
Assert.Equal(3, config.Processes[0].Args!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PowershellScript_DefaultValues_AreCorrect() {
|
||||
// Arrange & Act
|
||||
var script = new PowershellScript { Path = @"C:\test.ps1" };
|
||||
|
||||
// Assert
|
||||
Assert.Equal(@"C:\test.ps1", script.Path);
|
||||
Assert.True(script.IsSigned); // Default should be true
|
||||
Assert.False(script.Disabled); // Default should be false
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PowershellScript_CanSetIsSigned() {
|
||||
// Arrange
|
||||
var script = new PowershellScript { Path = @"C:\test.ps1" };
|
||||
|
||||
// Act
|
||||
script.IsSigned = false;
|
||||
|
||||
// Assert
|
||||
Assert.False(script.IsSigned);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PowershellScript_CanSetDisabled() {
|
||||
// Arrange
|
||||
var script = new PowershellScript { Path = @"C:\test.ps1" };
|
||||
|
||||
// Act
|
||||
script.Disabled = true;
|
||||
|
||||
// Assert
|
||||
Assert.True(script.Disabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfiguration_DefaultValues_AreCorrect() {
|
||||
// Arrange & Act
|
||||
var process = new ProcessConfiguration { Path = @"C:\app.exe" };
|
||||
|
||||
// Assert
|
||||
Assert.Equal(@"C:\app.exe", process.Path);
|
||||
Assert.Null(process.Args);
|
||||
Assert.False(process.RestartOnFailure); // Default should be false
|
||||
Assert.False(process.Disabled); // Default should be false
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfiguration_CanSetArgs() {
|
||||
// Arrange
|
||||
var process = new ProcessConfiguration { Path = @"C:\app.exe" };
|
||||
|
||||
// Act
|
||||
process.Args = ["arg1", "arg2"];
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(process.Args);
|
||||
Assert.Equal(2, process.Args.Length);
|
||||
Assert.Equal("arg1", process.Args[0]);
|
||||
Assert.Equal("arg2", process.Args[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessConfiguration_CanSetRestartOnFailure() {
|
||||
// Arrange
|
||||
var process = new ProcessConfiguration { Path = @"C:\app.exe" };
|
||||
|
||||
// Act
|
||||
process.RestartOnFailure = true;
|
||||
|
||||
// Assert
|
||||
Assert.True(process.RestartOnFailure);
|
||||
}
|
||||
}
|
||||
34
src/MaksIT.UScheduler.Tests/MaksIT.UScheduler.Tests.csproj
Normal file
34
src/MaksIT.UScheduler.Tests/MaksIT.UScheduler.Tests.csproj
Normal file
@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
|
||||
<PackageReference Include="xunit" Version="2.*" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.*">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.*">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaksIT.UScheduler\MaksIT.UScheduler.csproj" />
|
||||
<ProjectReference Include="..\MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -1,10 +1,15 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.0.11222.15 d18.0
|
||||
VisualStudioVersion = 18.0.11222.15
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler", "MaksIT.UScheduler\MaksIT.UScheduler.csproj", "{DE1F347C-D201-42E2-8D22-924508FD30AA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaksIT.UScheduler.Tests", "MaksIT.UScheduler.Tests\MaksIT.UScheduler.Tests.csproj", "{DC193ABC-89F8-131B-060F-6C3A3CE2652A}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler.ScheduleManager", "MaksIT.UScheduler.ScheduleManager\MaksIT.UScheduler.ScheduleManager.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MaksIT.UScheduler.Shared", "MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -15,6 +20,18 @@ Global
|
||||
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DE1F347C-D201-42E2-8D22-924508FD30AA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DC193ABC-89F8-131B-060F-6C3A3CE2652A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@ -1,25 +1,42 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MaksIT.UScheduler.Services;
|
||||
using MaksIT.UScheduler.Shared;
|
||||
|
||||
|
||||
namespace MaksIT.UScheduler.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that manages the execution of configured PowerShell scripts.
|
||||
/// Continuously monitors and launches scripts based on the application configuration.
|
||||
/// </summary>
|
||||
public sealed class PSScriptBackgroundService : BackgroundService {
|
||||
|
||||
private readonly ILogger<PSScriptBackgroundService> _logger;
|
||||
private readonly Configuration _configuration;
|
||||
private readonly PSScriptService _psScriptService;
|
||||
private readonly IPSScriptService _psScriptService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PSScriptBackgroundService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance for this service.</param>
|
||||
/// <param name="options">The configuration options containing PowerShell script definitions.</param>
|
||||
/// <param name="psScriptService">The PowerShell script service for executing scripts.</param>
|
||||
public PSScriptBackgroundService(
|
||||
ILogger<PSScriptBackgroundService> logger,
|
||||
IOptions<Configuration> options,
|
||||
PSScriptService psScriptService
|
||||
IPSScriptService psScriptService
|
||||
) {
|
||||
_logger = logger;
|
||||
_configuration = options.Value;
|
||||
_psScriptService = psScriptService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the background service, continuously launching configured PowerShell scripts in parallel.
|
||||
/// Scripts are checked and launched every 10 seconds.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">Cancellation token that signals when the service should stop.</param>
|
||||
/// <returns>A task representing the background operation.</returns>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
_logger.LogInformation("Starting PSScriptBackgroundService");
|
||||
|
||||
@ -29,14 +46,19 @@ public sealed class PSScriptBackgroundService : BackgroundService {
|
||||
while (!stoppingToken.IsCancellationRequested) {
|
||||
_logger.LogInformation("Checking for PowerShell scripts to run");
|
||||
|
||||
foreach (var psScript in psScripts) {
|
||||
var scriptPath = psScript.Path;
|
||||
// Launch all enabled scripts in parallel
|
||||
var scriptTasks = psScripts
|
||||
.Where(psScript => !psScript.Disabled && !string.IsNullOrEmpty(psScript.Path))
|
||||
.Select(psScript => {
|
||||
_logger.LogInformation($"Launching PowerShell script {psScript.Path}");
|
||||
return _psScriptService.RunScriptAsync(psScript.Path, psScript.IsSigned, stoppingToken);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (scriptPath == string.Empty)
|
||||
continue;
|
||||
|
||||
_logger.LogInformation($"Running PowerShell script {scriptPath}");
|
||||
_psScriptService.RunScript(scriptPath, psScript.IsSigned, stoppingToken);
|
||||
if (scriptTasks.Count > 0) {
|
||||
_logger.LogInformation($"Waiting for {scriptTasks.Count} PowerShell script(s) to complete");
|
||||
await Task.WhenAll(scriptTasks);
|
||||
_logger.LogInformation("All PowerShell scripts completed");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
@ -63,12 +85,19 @@ public sealed class PSScriptBackgroundService : BackgroundService {
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Stops the background service and terminates all running PowerShell scripts.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">Cancellation token for the stop operation.</param>
|
||||
/// <returns>A task representing the stop operation.</returns>
|
||||
public override Task StopAsync(CancellationToken stoppingToken) {
|
||||
// Perform cleanup tasks here
|
||||
_logger.LogInformation("Stopping PSScriptBackgroundService");
|
||||
|
||||
_psScriptService.TerminateAllScripts();
|
||||
|
||||
_logger.LogInformation("PSScriptBackgroundService stopped");
|
||||
|
||||
return Task.CompletedTask;
|
||||
return base.StopAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,25 +1,42 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using MaksIT.UScheduler.Services;
|
||||
using MaksIT.UScheduler.Shared;
|
||||
|
||||
|
||||
namespace MaksIT.UScheduler.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that manages the execution of configured external processes.
|
||||
/// Continuously monitors and launches processes based on the application configuration.
|
||||
/// </summary>
|
||||
public sealed class ProcessBackgroundService : BackgroundService {
|
||||
|
||||
private readonly ILogger<ProcessBackgroundService> _logger;
|
||||
private readonly Configuration _configuration;
|
||||
private readonly ProcessService _processService;
|
||||
private readonly IProcessService _processService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProcessBackgroundService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance for this service.</param>
|
||||
/// <param name="options">The configuration options containing process definitions.</param>
|
||||
/// <param name="processService">The process service for executing processes.</param>
|
||||
public ProcessBackgroundService(
|
||||
ILogger<ProcessBackgroundService> logger,
|
||||
IOptions<Configuration> options,
|
||||
ProcessService processService
|
||||
IProcessService processService
|
||||
) {
|
||||
_logger = logger;
|
||||
_configuration = options.Value;
|
||||
_processService = processService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the background service, continuously launching configured processes in parallel.
|
||||
/// Processes are checked and launched every 10 seconds.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">Cancellation token that signals when the service should stop.</param>
|
||||
/// <returns>A task representing the background operation.</returns>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
_logger.LogInformation("Starting ProcessBackgroundService");
|
||||
|
||||
@ -29,15 +46,20 @@ public sealed class ProcessBackgroundService : BackgroundService {
|
||||
while (!stoppingToken.IsCancellationRequested) {
|
||||
_logger.LogInformation("Checking for processes to run");
|
||||
|
||||
foreach (var process in processes) {
|
||||
var processPath = process.Path;
|
||||
var processArgs = process.Args;
|
||||
// Launch all enabled processes in parallel
|
||||
var processTasks = processes
|
||||
.Where(process => !process.Disabled && !string.IsNullOrEmpty(process.Path))
|
||||
.Select(process => {
|
||||
var argsString = process.Args != null ? string.Join(", ", process.Args) : "";
|
||||
_logger.LogInformation($"Launching process {process.Path} with arguments {argsString}");
|
||||
return _processService.RunProcessAsync(process.Path, process.Args, stoppingToken);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (processPath == string.Empty)
|
||||
continue;
|
||||
|
||||
_logger.LogInformation($"Running process {processPath} with arguments {string.Join(", ", processArgs)}");
|
||||
_processService.RunProcess(processPath, processArgs, stoppingToken);
|
||||
if (processTasks.Count > 0) {
|
||||
_logger.LogInformation($"Waiting for {processTasks.Count} process(es) to complete");
|
||||
await Task.WhenAll(processTasks);
|
||||
_logger.LogInformation("All processes completed");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
@ -63,12 +85,19 @@ public sealed class ProcessBackgroundService : BackgroundService {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the background service and terminates all running processes.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">Cancellation token for the stop operation.</param>
|
||||
/// <returns>A task representing the stop operation.</returns>
|
||||
public override Task StopAsync(CancellationToken stoppingToken) {
|
||||
// Perform cleanup tasks here
|
||||
_logger.LogInformation("Stopping ProcessBackgroundService");
|
||||
|
||||
_processService.TerminateAllProcesses();
|
||||
|
||||
_logger.LogInformation("All processes terminated");
|
||||
|
||||
return Task.CompletedTask;
|
||||
return base.StopAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
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; } = [];
|
||||
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
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
|
||||
@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Maksym Sadovnychyy (Maks-IT)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@ -1,8 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>1.0.1</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>dotnet-UScheduler-040d8105-9e07-4024-a632-cbe091387b66</UserSecretsId>
|
||||
@ -13,15 +13,19 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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" />
|
||||
<ProjectReference Include="..\MaksIT.UScheduler.Shared\MaksIT.UScheduler.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.2" />
|
||||
<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.Diagnostics.PerformanceCounter" Version="10.0.2" />
|
||||
<PackageReference Include="System.Management.Automation" Version="7.5.4" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -30,19 +34,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="CHANGELOG.md">
|
||||
<None Include="..\..\README.md;..\..\LICENSE.md;..\..\CHANGELOG.md">
|
||||
<Link>%(Filename)%(Extension)</Link>
|
||||
<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">
|
||||
<None Include="..\..\badges\**\*" Link="badges\%(RecursiveDir)%(Filename)%(Extension)">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
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
|
||||
@ -2,12 +2,20 @@ using MaksIT.Core.Logging;
|
||||
using MaksIT.UScheduler;
|
||||
using MaksIT.UScheduler.BackgroundServices;
|
||||
using MaksIT.UScheduler.Services;
|
||||
using MaksIT.UScheduler.Shared;
|
||||
using Microsoft.Extensions.Logging.Configuration;
|
||||
using Microsoft.Extensions.Logging.EventLog;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
|
||||
// read configuration from appsettings.json
|
||||
// OS Guard - This application only supports Windows
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||
Console.WriteLine("Error: MaksIT.UScheduler only supports Windows.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Read configuration from appsettings.json
|
||||
var configurationRoot = new ConfigurationBuilder()
|
||||
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
@ -15,7 +23,46 @@ var configurationRoot = new ConfigurationBuilder()
|
||||
|
||||
// Configure strongly typed settings objects
|
||||
var configurationSection = configurationRoot.GetSection("Configuration");
|
||||
var appSettings = configurationSection.Get<Configuration>() ?? throw new ArgumentNullException();
|
||||
var appSettings = configurationSection.Get<Configuration>() ?? throw new InvalidOperationException("Configuration section is missing.");
|
||||
if (string.IsNullOrWhiteSpace(appSettings.LogDir))
|
||||
throw new InvalidOperationException("Configuration.LogDir is required. Set it in appsettings.json (e.g. \".\\Logs\").");
|
||||
|
||||
// Handle command-line arguments for service management
|
||||
if (args.Length > 0) {
|
||||
var command = args[0].ToLowerInvariant();
|
||||
var exePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "MaksIT.UScheduler.exe");
|
||||
var serviceName = appSettings.ServiceName;
|
||||
var serviceDescription = "Windows service that allows you to schedule and invoke PowerShell Scripts and Processes";
|
||||
|
||||
switch (command) {
|
||||
case "--install":
|
||||
case "-i":
|
||||
return InstallService(serviceName, exePath, serviceDescription);
|
||||
|
||||
case "--uninstall":
|
||||
case "-u":
|
||||
return UninstallService(serviceName);
|
||||
|
||||
case "--start":
|
||||
return StartService(serviceName);
|
||||
|
||||
case "--stop":
|
||||
return StopService(serviceName);
|
||||
|
||||
case "--status":
|
||||
return GetServiceStatus(serviceName);
|
||||
|
||||
case "--help":
|
||||
case "-h":
|
||||
case "/?":
|
||||
PrintHelp(serviceName);
|
||||
return 0;
|
||||
|
||||
default:
|
||||
// If not a recognized command, continue with normal startup
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Services.AddWindowsService(options => {
|
||||
@ -25,10 +72,11 @@ builder.Services.AddWindowsService(options => {
|
||||
// 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");
|
||||
// Logging: resolve LogDir (required); relative paths are resolved against application base directory
|
||||
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
var logPath = Path.IsPathRooted(appSettings.LogDir)
|
||||
? appSettings.LogDir
|
||||
: Path.GetFullPath(Path.Combine(baseDir, appSettings.LogDir));
|
||||
|
||||
if (!Directory.Exists(logPath)) {
|
||||
Directory.CreateDirectory(logPath);
|
||||
@ -36,15 +84,13 @@ if (!Directory.Exists(logPath)) {
|
||||
|
||||
builder.Logging.AddConsoleLogger(logPath);
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||
LoggerProviderOptions.RegisterProviderOptions<
|
||||
EventLogSettings, EventLogLoggerProvider>(builder.Services);
|
||||
}
|
||||
LoggerProviderOptions.RegisterProviderOptions<
|
||||
EventLogSettings, EventLogLoggerProvider>(builder.Services);
|
||||
|
||||
builder.Services.AddSingleton<ProcessService>();
|
||||
builder.Services.AddSingleton<IProcessService, ProcessService>();
|
||||
builder.Services.AddHostedService<ProcessBackgroundService>();
|
||||
|
||||
builder.Services.AddSingleton<PSScriptService>();
|
||||
builder.Services.AddSingleton<IPSScriptService, PSScriptService>();
|
||||
builder.Services.AddHostedService<PSScriptBackgroundService>();
|
||||
|
||||
IHost host = builder.Build();
|
||||
@ -55,3 +101,138 @@ var testLogger = loggerFactory.CreateLogger("LoggerTest");
|
||||
testLogger.LogInformation("Logger test: This should appear in your log file.");
|
||||
|
||||
host.Run();
|
||||
return 0;
|
||||
|
||||
|
||||
// Service management functions
|
||||
static int InstallService(string serviceName, string exePath, string description) {
|
||||
Console.WriteLine($"Installing service '{serviceName}'...");
|
||||
|
||||
// Create the service
|
||||
var createResult = RunScCommand($"create \"{serviceName}\" binpath=\"{exePath}\" start=auto");
|
||||
if (createResult != 0) {
|
||||
Console.WriteLine("Failed to create service.");
|
||||
return createResult;
|
||||
}
|
||||
|
||||
// Set description
|
||||
var descResult = RunScCommand($"description \"{serviceName}\" \"{description}\"");
|
||||
if (descResult != 0) {
|
||||
Console.WriteLine("Warning: Failed to set service description.");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Service '{serviceName}' installed successfully.");
|
||||
Console.WriteLine($"Use '--start' to start the service or start it from services.msc");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int UninstallService(string serviceName) {
|
||||
Console.WriteLine($"Stopping service '{serviceName}'...");
|
||||
RunScCommand($"stop \"{serviceName}\"");
|
||||
|
||||
Console.WriteLine($"Uninstalling service '{serviceName}'...");
|
||||
var result = RunScCommand($"delete \"{serviceName}\"");
|
||||
|
||||
if (result == 0) {
|
||||
Console.WriteLine($"Service '{serviceName}' uninstalled successfully.");
|
||||
}
|
||||
else {
|
||||
Console.WriteLine("Failed to uninstall service.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int StartService(string serviceName) {
|
||||
Console.WriteLine($"Starting service '{serviceName}'...");
|
||||
var result = RunScCommand($"start \"{serviceName}\"");
|
||||
|
||||
if (result == 0) {
|
||||
Console.WriteLine($"Service '{serviceName}' started successfully.");
|
||||
}
|
||||
else {
|
||||
Console.WriteLine("Failed to start service.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int StopService(string serviceName) {
|
||||
Console.WriteLine($"Stopping service '{serviceName}'...");
|
||||
var result = RunScCommand($"stop \"{serviceName}\"");
|
||||
|
||||
if (result == 0) {
|
||||
Console.WriteLine($"Service '{serviceName}' stopped successfully.");
|
||||
}
|
||||
else {
|
||||
Console.WriteLine("Failed to stop service.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int GetServiceStatus(string serviceName) {
|
||||
return RunScCommand($"query \"{serviceName}\"");
|
||||
}
|
||||
|
||||
static int RunScCommand(string arguments) {
|
||||
try {
|
||||
var process = new Process {
|
||||
StartInfo = new ProcessStartInfo {
|
||||
FileName = "sc.exe",
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
|
||||
process.WaitForExit();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
Console.WriteLine(output);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
Console.WriteLine(error);
|
||||
|
||||
return process.ExitCode;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Console.WriteLine($"Error executing sc.exe: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static void PrintHelp(string serviceName) {
|
||||
Console.WriteLine($"""
|
||||
MaksIT.UScheduler - Windows Service Scheduler
|
||||
|
||||
Usage: MaksIT.UScheduler.exe [command]
|
||||
|
||||
Commands:
|
||||
--install, -i Install the Windows service
|
||||
--uninstall, -u Uninstall the Windows service
|
||||
--start Start the service
|
||||
--stop Stop the service
|
||||
--status Query service status
|
||||
--help, -h Show this help message
|
||||
|
||||
Configuration:
|
||||
Service Name: {serviceName}
|
||||
Config File: appsettings.json
|
||||
|
||||
Examples:
|
||||
MaksIT.UScheduler.exe --install # Install and register the service
|
||||
MaksIT.UScheduler.exe --start # Start the service
|
||||
MaksIT.UScheduler.exe --stop # Stop the service
|
||||
MaksIT.UScheduler.exe --uninstall # Remove the service
|
||||
|
||||
Note: Service management commands require administrator privileges.
|
||||
""");
|
||||
}
|
||||
32
src/MaksIT.UScheduler/Services/IPSScriptService.cs
Normal file
32
src/MaksIT.UScheduler/Services/IPSScriptService.cs
Normal file
@ -0,0 +1,32 @@
|
||||
namespace MaksIT.UScheduler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for executing and managing PowerShell scripts.
|
||||
/// </summary>
|
||||
public interface IPSScriptService : IDisposable {
|
||||
/// <summary>
|
||||
/// Executes a PowerShell script asynchronously with optional signature verification.
|
||||
/// </summary>
|
||||
/// <param name="scriptPath">The path to the PowerShell script to execute.</param>
|
||||
/// <param name="signed">If true, validates the script's Authenticode signature before execution.</param>
|
||||
/// <param name="stoppingToken">Cancellation token to signal when script execution should stop.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task RunScriptAsync(string scriptPath, bool signed, CancellationToken stoppingToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of script paths that are currently being executed.
|
||||
/// </summary>
|
||||
/// <returns>A list of script paths currently running.</returns>
|
||||
List<string> GetRunningScriptTasks();
|
||||
|
||||
/// <summary>
|
||||
/// Terminates a running PowerShell script by its path.
|
||||
/// </summary>
|
||||
/// <param name="scriptPath">The path of the script to terminate.</param>
|
||||
void TerminateScript(string scriptPath);
|
||||
|
||||
/// <summary>
|
||||
/// Terminates all currently running PowerShell scripts.
|
||||
/// </summary>
|
||||
void TerminateAllScripts();
|
||||
}
|
||||
36
src/MaksIT.UScheduler/Services/IProcessService.cs
Normal file
36
src/MaksIT.UScheduler/Services/IProcessService.cs
Normal file
@ -0,0 +1,36 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
|
||||
|
||||
namespace MaksIT.UScheduler.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for managing and executing external processes.
|
||||
/// </summary>
|
||||
public interface IProcessService {
|
||||
/// <summary>
|
||||
/// Starts and monitors an external process asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="processPath">The path to the executable to run.</param>
|
||||
/// <param name="args">Optional command-line arguments to pass to the process.</param>
|
||||
/// <param name="stoppingToken">Cancellation token to signal when the process should stop.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task RunProcessAsync(string processPath, string[]? args, CancellationToken stoppingToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dictionary of currently running processes.
|
||||
/// </summary>
|
||||
/// <returns>A concurrent dictionary mapping process IDs to their Process objects.</returns>
|
||||
ConcurrentDictionary<int, Process> GetRunningProcesses();
|
||||
|
||||
/// <summary>
|
||||
/// Terminates a running process by its ID.
|
||||
/// </summary>
|
||||
/// <param name="processId">The ID of the process to terminate.</param>
|
||||
void TerminateProcessById(int processId);
|
||||
|
||||
/// <summary>
|
||||
/// Terminates all currently running processes managed by this service.
|
||||
/// </summary>
|
||||
void TerminateAllProcesses();
|
||||
}
|
||||
@ -1,65 +1,104 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Management.Automation;
|
||||
using System.Management.Automation.Runspaces;
|
||||
using MaksIT.UScheduler.Shared.Helpers;
|
||||
using MaksIT.UScheduler.Shared.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.UScheduler.Services;
|
||||
|
||||
public sealed class PSScriptService {
|
||||
/// <summary>
|
||||
/// Service responsible for executing and managing PowerShell scripts.
|
||||
/// Provides parallel script execution using a RunspacePool and supports
|
||||
/// signature verification for signed scripts.
|
||||
/// </summary>
|
||||
public sealed class PSScriptService : IPSScriptService {
|
||||
|
||||
private readonly ILogger<PSScriptService> _logger;
|
||||
private readonly ConcurrentDictionary<string, PowerShell> _runningScripts = new ConcurrentDictionary<string, PowerShell>();
|
||||
private readonly Runspace _rs = RunspaceFactory.CreateRunspace();
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ConcurrentDictionary<string, PowerShell> _runningScripts = new();
|
||||
private readonly RunspacePool _runspacePool;
|
||||
private bool _disposed;
|
||||
|
||||
public PSScriptService(ILogger<PSScriptService> logger) {
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PSScriptService"/> class.
|
||||
/// Creates a RunspacePool for parallel script execution.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance for this service.</param>
|
||||
/// <param name="loggerFactory">The logger factory for creating script-specific loggers.</param>
|
||||
public PSScriptService(
|
||||
ILogger<PSScriptService> logger,
|
||||
ILoggerFactory loggerFactory
|
||||
) {
|
||||
_logger = logger;
|
||||
if (_rs.RunspaceStateInfo.State != RunspaceState.Opened) {
|
||||
_rs.Open();
|
||||
_logger.LogInformation($"Runspace opened");
|
||||
}
|
||||
_loggerFactory = loggerFactory;
|
||||
|
||||
// Create a RunspacePool to allow parallel script execution
|
||||
_runspacePool = RunspaceFactory.CreateRunspacePool(1, Environment.ProcessorCount);
|
||||
_runspacePool.Open();
|
||||
_logger.LogInformation($"RunspacePool opened with max {Environment.ProcessorCount} runspaces");
|
||||
}
|
||||
|
||||
public Task RunScript(string scriptPath, bool signed, CancellationToken stoppingToken) {
|
||||
_logger.LogInformation($"Preparing to run script {scriptPath}");
|
||||
/// <summary>
|
||||
/// Executes a PowerShell script asynchronously with optional signature verification.
|
||||
/// Automatically passes Automated and CurrentDateTimeUtc parameters to the script.
|
||||
/// </summary>
|
||||
/// <param name="scriptPath">The path to the PowerShell script to execute.</param>
|
||||
/// <param name="signed">If true, validates the script's Authenticode signature before execution.</param>
|
||||
/// <param name="stoppingToken">Cancellation token to signal when script execution should stop.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async Task RunScriptAsync(string scriptPath, bool signed, CancellationToken stoppingToken) {
|
||||
// Resolve relative paths against application base directory
|
||||
var resolvedPath = PathHelper.ResolvePath(scriptPath);
|
||||
|
||||
if (GetRunningScriptTasks().Contains(scriptPath)) {
|
||||
_logger.LogInformation($"PowerShell script {scriptPath} is already running");
|
||||
return Task.CompletedTask;
|
||||
_logger.LogInformation($"Preparing to run script {resolvedPath}");
|
||||
|
||||
if (GetRunningScriptTasks().Contains(resolvedPath)) {
|
||||
_logger.LogInformation($"PowerShell script {resolvedPath} is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(scriptPath)) {
|
||||
_logger.LogError($"Script file {scriptPath} does not exist");
|
||||
return Task.CompletedTask;
|
||||
if (!File.Exists(resolvedPath)) {
|
||||
_logger.LogError($"Script file {resolvedPath} does not exist");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryUnblockScript(scriptPath)) {
|
||||
_logger.LogError($"Script {scriptPath} could not be unblocked. Aborting execution.");
|
||||
return Task.CompletedTask;
|
||||
if (!TryUnblockScript(resolvedPath)) {
|
||||
_logger.LogError($"Script {resolvedPath} could not be unblocked. Aborting execution.");
|
||||
return;
|
||||
}
|
||||
|
||||
var ps = PowerShell.Create();
|
||||
ps.Runspace = _rs;
|
||||
_runningScripts.TryAdd(scriptPath, ps);
|
||||
ps.RunspacePool = _runspacePool;
|
||||
|
||||
if (!_runningScripts.TryAdd(resolvedPath, ps)) {
|
||||
_logger.LogWarning($"Script {resolvedPath} was already added by another thread");
|
||||
ps.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Set execution policy
|
||||
var scriptPolicy = signed ? "AllSigned" : "Unrestricted";
|
||||
ps.AddScript($"Set-ExecutionPolicy -Scope Process -ExecutionPolicy {scriptPolicy}");
|
||||
ps.Invoke();
|
||||
await Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);
|
||||
|
||||
if (signed) {
|
||||
ps.Commands.Clear();
|
||||
ps.AddScript($"Get-AuthenticodeSignature \"{scriptPath}\"");
|
||||
var signatureResults = ps.Invoke();
|
||||
ps.AddScript($"Get-AuthenticodeSignature \"{resolvedPath}\"");
|
||||
var signatureResults = await Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);
|
||||
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.LogWarning($"Script {resolvedPath} signature is invalid. Correct and restart the service.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Invoking: {scriptPath}");
|
||||
stoppingToken.ThrowIfCancellationRequested();
|
||||
|
||||
_logger.LogInformation($"Invoking: {resolvedPath}");
|
||||
|
||||
ps.Commands.Clear();
|
||||
var myCommand = new Command(scriptPath);
|
||||
var myCommand = new Command(resolvedPath);
|
||||
|
||||
var currentDateTimeUtcString = DateTime.UtcNow.ToString("o");
|
||||
myCommand.Parameters.Add(new CommandParameter("Automated", true));
|
||||
@ -68,35 +107,45 @@ public sealed class PSScriptService {
|
||||
|
||||
_logger.LogInformation($"Added parameters: Automated=true, CurrentDateTimeUtc={currentDateTimeUtcString}");
|
||||
|
||||
// Execute asynchronously
|
||||
var outputResults = await Task.Factory.FromAsync(ps.BeginInvoke(), ps.EndInvoke);
|
||||
|
||||
#region Script Output Logging
|
||||
var scriptLogger = _loggerFactory.CreateFolderLogger(resolvedPath);
|
||||
|
||||
// Log standard output
|
||||
var outputResults = ps.Invoke();
|
||||
if (outputResults != null && outputResults.Count > 0) {
|
||||
foreach (var outputItem in outputResults) {
|
||||
_logger.LogInformation($"[PS Output] {outputItem}");
|
||||
scriptLogger.LogInformation($"[PS Output] {outputItem}");
|
||||
}
|
||||
}
|
||||
|
||||
// Log errors
|
||||
if (ps.Streams.Error.Count > 0) {
|
||||
foreach (var errorItem in ps.Streams.Error) {
|
||||
_logger.LogError($"[PS Error] {errorItem}");
|
||||
scriptLogger.LogError($"[PS Error] {errorItem}");
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
_logger.LogInformation($"Stopping script {scriptPath} due to cancellation request");
|
||||
_logger.LogInformation($"Stopping script {resolvedPath} due to cancellation request");
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError($"Error running script {scriptPath}: {ex.Message}");
|
||||
_logger.LogError($"Error running script {resolvedPath}: {ex.Message}");
|
||||
}
|
||||
finally {
|
||||
TerminateScript(scriptPath);
|
||||
_logger.LogInformation($"Script {scriptPath} completed and removed from running scripts");
|
||||
TerminateScript(resolvedPath);
|
||||
_logger.LogInformation($"Script {resolvedPath} completed and removed from running scripts");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <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";
|
||||
@ -112,20 +161,57 @@ public sealed class PSScriptService {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of script paths that are currently being executed.
|
||||
/// </summary>
|
||||
/// <returns>A list of script paths currently running.</returns>
|
||||
public List<string> GetRunningScriptTasks() {
|
||||
_logger.LogInformation($"Retrieving running script tasks. Current count: {_runningScripts.Count}");
|
||||
return _runningScripts.Keys.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminates a running PowerShell script by its path.
|
||||
/// Stops the script execution and disposes of its PowerShell instance.
|
||||
/// </summary>
|
||||
/// <param name="scriptPath">The path of the script to terminate.</param>
|
||||
public void TerminateScript(string scriptPath) {
|
||||
_logger.LogInformation($"Attempting to terminate script {scriptPath}");
|
||||
|
||||
if (_runningScripts.TryRemove(scriptPath, out var ps)) {
|
||||
ps.Stop();
|
||||
ps.Dispose();
|
||||
_logger.LogInformation($"Script {scriptPath} terminated");
|
||||
}
|
||||
else {
|
||||
_logger.LogWarning($"Failed to terminate script {scriptPath}. Script not found.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminates all currently running PowerShell scripts.
|
||||
/// </summary>
|
||||
public void TerminateAllScripts() {
|
||||
_logger.LogInformation("Terminating all running scripts");
|
||||
|
||||
foreach (var scriptPath in _runningScripts.Keys.ToList()) {
|
||||
TerminateScript(scriptPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases all resources used by the <see cref="PSScriptService"/>.
|
||||
/// Terminates all running scripts and closes the RunspacePool.
|
||||
/// </summary>
|
||||
public void Dispose() {
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
TerminateAllScripts();
|
||||
|
||||
_runspacePool?.Close();
|
||||
_runspacePool?.Dispose();
|
||||
_logger.LogInformation("RunspacePool disposed");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,90 +1,126 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using System.Collections.Concurrent;
|
||||
using MaksIT.UScheduler.Shared.Helpers;
|
||||
using MaksIT.UScheduler.Shared.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.UScheduler.Services;
|
||||
|
||||
public sealed class ProcessService {
|
||||
/// <summary>
|
||||
/// Service responsible for managing and executing external processes.
|
||||
/// Tracks running processes and provides methods for starting, monitoring, and terminating them.
|
||||
/// </summary>
|
||||
public sealed class ProcessService : IProcessService {
|
||||
|
||||
private readonly ILogger<ProcessService> _logger;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ConcurrentDictionary<int, Process> _runningProcesses = new();
|
||||
|
||||
public ProcessService(ILogger<ProcessService> logger) {
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProcessService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance for this service.</param>
|
||||
/// <param name="loggerFactory">The logger factory for creating process-specific loggers.</param>
|
||||
public ProcessService(
|
||||
ILogger<ProcessService> logger,
|
||||
ILoggerFactory loggerFactory
|
||||
) {
|
||||
_logger = logger;
|
||||
_loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
public async Task RunProcess(string processPath, string[] args, CancellationToken stoppingToken) {
|
||||
_logger.LogInformation($"Starting process {processPath} with arguments {string.Join(", ", args)}");
|
||||
/// <summary>
|
||||
/// Starts and monitors an external process asynchronously.
|
||||
/// If the process exits with a non-zero code, it will be automatically restarted.
|
||||
/// </summary>
|
||||
/// <param name="processPath">The path to the executable to run.</param>
|
||||
/// <param name="args">Optional command-line arguments to pass to the process.</param>
|
||||
/// <param name="stoppingToken">Cancellation token to signal when the process should stop.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public async Task RunProcessAsync(string processPath, string[]? args, CancellationToken stoppingToken) {
|
||||
// Resolve relative paths against application base directory
|
||||
var resolvedPath = PathHelper.ResolvePath(processPath);
|
||||
var processLogger = _loggerFactory.CreateFolderLogger(resolvedPath);
|
||||
var argsString = args != null ? string.Join(", ", args) : "";
|
||||
|
||||
processLogger.LogInformation($"Starting process {resolvedPath} with arguments {argsString}");
|
||||
|
||||
Process? process = null;
|
||||
|
||||
try {
|
||||
if (GetRunningProcesses().Any(x => x.Value.StartInfo.FileName == processPath)) {
|
||||
_logger.LogInformation($"Process {processPath} is already running");
|
||||
if (GetRunningProcesses().Any(x => x.Value.StartInfo.FileName == resolvedPath)) {
|
||||
processLogger.LogInformation($"Process {resolvedPath} is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
process = new Process();
|
||||
|
||||
process.StartInfo = new ProcessStartInfo {
|
||||
FileName = processPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(processPath),
|
||||
FileName = resolvedPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(resolvedPath),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
|
||||
foreach (var arg in args)
|
||||
process.StartInfo.ArgumentList.Add(arg);
|
||||
if (args != null) {
|
||||
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}");
|
||||
processLogger.LogInformation($"Process {resolvedPath} 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);
|
||||
processLogger.LogWarning($"Process {resolvedPath} exited with code {process.ExitCode}, restarting...");
|
||||
await RunProcessAsync(resolvedPath, args, stoppingToken);
|
||||
}
|
||||
else {
|
||||
_logger.LogInformation($"Process {processPath} completed successfully");
|
||||
processLogger.LogInformation($"Process {resolvedPath} completed successfully with exit code {process.ExitCode}");
|
||||
}
|
||||
}
|
||||
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");
|
||||
processLogger.LogInformation($"Process {resolvedPath} was canceled");
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError($"Error running process {processPath}: {ex.Message}");
|
||||
processLogger.LogError($"Error running process {resolvedPath}: {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");
|
||||
processLogger.LogInformation($"Process {resolvedPath} with ID {process.Id} removed from running processes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dictionary of currently running processes.
|
||||
/// </summary>
|
||||
/// <returns>A concurrent dictionary mapping process IDs to their Process objects.</returns>
|
||||
public ConcurrentDictionary<int, Process> GetRunningProcesses() {
|
||||
_logger.LogInformation($"Retrieving running processes. Current count: {_runningProcesses.Count}");
|
||||
return _runningProcesses;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminates a running process by its ID.
|
||||
/// Recursively attempts to kill the process until it has exited.
|
||||
/// </summary>
|
||||
/// <param name="processId">The ID of the process to terminate.</param>
|
||||
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}");
|
||||
@ -92,7 +128,17 @@ public sealed class ProcessService {
|
||||
|
||||
// Check if the process has exited
|
||||
if (!processToTerminate.HasExited) {
|
||||
_logger.LogWarning($"Failed to terminate process {processId}. Process still running.");
|
||||
TerminateProcessById(processId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminates all currently running processes managed by this service.
|
||||
/// </summary>
|
||||
public void TerminateAllProcesses() {
|
||||
_logger.LogInformation("Terminating all running processes");
|
||||
|
||||
foreach (var processId in _runningProcesses.Keys.ToList()) {
|
||||
TerminateProcessById(processId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
sc.exe delete "MaksIT.UScheduler Service"
|
||||
pause
|
||||
@ -13,7 +13,32 @@
|
||||
}
|
||||
},
|
||||
"Configuration": {
|
||||
"LogDir": ".\\logs",
|
||||
"Powershell": [
|
||||
{
|
||||
"Path": "..\\..\\..\\..\\..\\Scripts\\File-Sync\\file-sync.ps1",
|
||||
"Name": "File Sync",
|
||||
"IsSigned": false,
|
||||
"Disabled": true
|
||||
},
|
||||
{
|
||||
"Path": "..\\..\\..\\..\\..\\Scripts\\HyperV-Backup\\hyper-v-backup.ps1",
|
||||
"Name": "HyperV Backup",
|
||||
"IsSigned": false,
|
||||
"Disabled": true
|
||||
},
|
||||
{
|
||||
"Path": "..\\..\\..\\..\\..\\Scripts\\Native-Sync\\native-sync.ps1",
|
||||
"Name": "Native Sync",
|
||||
"IsSigned": false,
|
||||
"Disabled": true
|
||||
},
|
||||
{
|
||||
"Path": "..\\..\\..\\..\\..\\Scripts\\Windows-Update\\windows-update.ps1",
|
||||
"Name": "Windows Update",
|
||||
"IsSigned": false,
|
||||
"Disabled": true
|
||||
}
|
||||
],
|
||||
|
||||
"Processes": [
|
||||
|
||||
@ -1,173 +0,0 @@
|
||||
# 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."
|
||||
@ -6,8 +6,12 @@
|
||||
"lastModified": "2026-01-26",
|
||||
"schedule": {
|
||||
"runMonth": [],
|
||||
"runWeekday": ["Monday"],
|
||||
"runTime": ["00:00"],
|
||||
"runWeekday": [
|
||||
"Monday"
|
||||
],
|
||||
"runTime": [
|
||||
"00:00"
|
||||
],
|
||||
"minIntervalMinutes": 10
|
||||
},
|
||||
"freeFileSyncExe": "C:\\Program Files\\FreeFileSync\\FreeFileSync.exe",
|
||||
@ -62,10 +62,7 @@ Windows-Update/
|
||||
"runWeekday": ["Wednesday"],
|
||||
"runTime": ["02:00"]
|
||||
},
|
||||
"updateCategories": [
|
||||
"Critical Updates",
|
||||
"Security Updates"
|
||||
]
|
||||
"updateCategories": "all"
|
||||
}
|
||||
```
|
||||
|
||||
@ -92,20 +89,38 @@ Windows-Update/
|
||||
|
||||
### Update Categories
|
||||
|
||||
Available categories to install:
|
||||
The `updateCategories` setting supports two formats:
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| `"all"` | Install updates from all categories (default) |
|
||||
| `["Category1", "Category2"]` | Install only from specified categories |
|
||||
|
||||
**Available Categories:**
|
||||
|
||||
| Category | Description |
|
||||
|----------|-------------|
|
||||
| `Critical Updates` | Critical security and stability updates |
|
||||
| `Security Updates` | Security-focused updates |
|
||||
| `Definition Updates` | Antivirus and malware definition updates |
|
||||
| `Definition Updates` | Antivirus/Defender definition updates (e.g., KB2267602) |
|
||||
| `Update Rollups` | Cumulative update packages |
|
||||
| `Updates` | General updates |
|
||||
| `Feature Packs` | New feature additions |
|
||||
| `Service Packs` | Major cumulative updates |
|
||||
| `Tools` | System tools and utilities |
|
||||
| `Drivers` | Hardware driver updates |
|
||||
| `Upgrades` | Windows version upgrades |
|
||||
|
||||
**Example:**
|
||||
**Examples:**
|
||||
|
||||
Install all updates (default):
|
||||
```json
|
||||
{
|
||||
"updateCategories": "all"
|
||||
}
|
||||
```
|
||||
|
||||
Security-focused updates only:
|
||||
```json
|
||||
{
|
||||
"updateCategories": [
|
||||
@ -116,6 +131,22 @@ Available categories to install:
|
||||
}
|
||||
```
|
||||
|
||||
Everything except drivers:
|
||||
```json
|
||||
{
|
||||
"updateCategories": [
|
||||
"Critical Updates",
|
||||
"Security Updates",
|
||||
"Definition Updates",
|
||||
"Update Rollups",
|
||||
"Updates",
|
||||
"Feature Packs",
|
||||
"Service Packs",
|
||||
"Tools"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Exclusions
|
||||
|
||||
| Property | Type | Description | Example |
|
||||
@ -268,6 +299,8 @@ When `-Automated` is specified:
|
||||
[INFO] ==========================================
|
||||
[INFO] Windows Update Process Started
|
||||
[INFO] Script Version: 1.0.0 (2026-01-28)
|
||||
[INFO] Update Categories: ALL (installing all available updates)
|
||||
[Info] Available: Critical Updates, Definition Updates, Security Updates, Update Rollups, Updates
|
||||
[INFO] ==========================================
|
||||
[SUCCESS] PSWindowsUpdate module loaded
|
||||
[INFO] Running pre-update checks...
|
||||
@ -422,12 +455,13 @@ Test without installing updates (set in scriptsettings.json):
|
||||
|
||||
1. **Test First** - Always test with `dryRun: true` before actual execution
|
||||
2. **Schedule Wisely** - Run during maintenance windows (nights, weekends)
|
||||
3. **Start Conservative** - Begin with Critical/Security updates only
|
||||
4. **Monitor Results** - Review update reports and logs regularly
|
||||
5. **Backup First** - Ensure system backups before major updates
|
||||
6. **Reboot Testing** - Test `rebootBehavior: "auto"` in non-production environment first
|
||||
7. **Exclusion Management** - Keep exclusions list minimal and documented
|
||||
8. **Review Failures** - Investigate and resolve failed updates promptly
|
||||
3. **Use "all" Categories** - Default `"all"` ensures no updates are missed (including Defender definitions)
|
||||
4. **Use Exclusions** - Exclude specific problematic updates by KB number or title pattern instead of limiting categories
|
||||
5. **Monitor Results** - Review update reports and logs regularly
|
||||
6. **Backup First** - Ensure system backups before major updates
|
||||
7. **Reboot Testing** - Test `rebootBehavior: "auto"` in non-production environment first
|
||||
8. **Exclusion Management** - Keep exclusions list minimal and documented
|
||||
9. **Review Failures** - Investigate and resolve failed updates promptly
|
||||
|
||||
## Security Considerations
|
||||
|
||||
@ -457,6 +491,12 @@ Test without installing updates (set in scriptsettings.json):
|
||||
|
||||
## Version History
|
||||
|
||||
### 1.0.1 (2026-01-30)
|
||||
- Added `"all"` option for updateCategories (new default)
|
||||
- Fixed category matching for PSWindowsUpdate Category objects
|
||||
- Added `Updates` and `Upgrades` to documented categories
|
||||
- Improved logging to show category mode at startup
|
||||
|
||||
### 1.0.0 (2026-01-28)
|
||||
- Initial release
|
||||
- PSWindowsUpdate integration
|
||||
@ -10,12 +10,7 @@
|
||||
"runTime": ["02:00"],
|
||||
"minIntervalMinutes": 60
|
||||
},
|
||||
"updateCategories": [
|
||||
"Critical Updates",
|
||||
"Security Updates",
|
||||
"Definition Updates",
|
||||
"Update Rollups"
|
||||
],
|
||||
"updateCategories": "all",
|
||||
"exclusions": {
|
||||
"kbNumbers": [],
|
||||
"titlePatterns": [
|
||||
@ -33,7 +28,7 @@
|
||||
"dryRun": false
|
||||
},
|
||||
"reporting": {
|
||||
"generateReport": true,
|
||||
"generateReport": false,
|
||||
"emailNotification": false,
|
||||
"emailSettings": {
|
||||
"smtpServer": "smtp.example.com",
|
||||
@ -53,7 +48,7 @@
|
||||
"runTime": "Array of UTC times in HH:mm format when updates should run.",
|
||||
"minIntervalMinutes": "Minimum minutes between update runs to prevent duplicate executions."
|
||||
},
|
||||
"updateCategories": "Array of update categories to install. Available: 'Critical Updates', 'Security Updates', 'Definition Updates', 'Update Rollups', 'Feature Packs', 'Service Packs', 'Tools', 'Drivers'",
|
||||
"updateCategories": "Set to 'all' to install all updates regardless of category, or provide an array of specific categories. Available categories: 'Critical Updates', 'Security Updates', 'Definition Updates', 'Update Rollups', 'Updates', 'Feature Packs', 'Service Packs', 'Tools', 'Drivers', 'Upgrades'",
|
||||
"exclusions": {
|
||||
"kbNumbers": "Array of KB numbers to exclude (e.g. 'KB5034441')",
|
||||
"titlePatterns": "Array of wildcard patterns to exclude by title (e.g. '*Preview*', '*Optional*')"
|
||||
@ -55,7 +55,7 @@ catch {
|
||||
# Process Settings =========================================================
|
||||
|
||||
# Validate required settings
|
||||
$requiredSettings = @('updateCategories', 'preChecks', 'options')
|
||||
$requiredSettings = @('preChecks', 'options')
|
||||
foreach ($setting in $requiredSettings) {
|
||||
if (-not $settings.$setting) {
|
||||
Write-Error "Required setting '$setting' is missing or empty in $settingsFile"
|
||||
@ -63,13 +63,34 @@ foreach ($setting in $requiredSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
# Validate updateCategories separately (can be string "all" or array)
|
||||
if (-not $settings.updateCategories) {
|
||||
Write-Error "Required setting 'updateCategories' is missing in $settingsFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Extract settings
|
||||
$UpdateCategories = $settings.updateCategories
|
||||
$UpdateCategoriesSetting = $settings.updateCategories
|
||||
$Exclusions = $settings.exclusions
|
||||
$PreChecks = $settings.preChecks
|
||||
$Options = $settings.options
|
||||
$Reporting = $settings.reporting
|
||||
|
||||
# Process updateCategories - can be "all" or an array of category names
|
||||
$InstallAllCategories = $false
|
||||
$UpdateCategories = @()
|
||||
|
||||
if ($UpdateCategoriesSetting -is [string] -and $UpdateCategoriesSetting -eq "all") {
|
||||
$InstallAllCategories = $true
|
||||
}
|
||||
elseif ($UpdateCategoriesSetting -is [array]) {
|
||||
$UpdateCategories = $UpdateCategoriesSetting
|
||||
}
|
||||
else {
|
||||
Write-Error "Invalid updateCategories setting. Must be 'all' or an array of category names."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Get DryRun from settings
|
||||
$DryRun = $Options.dryRun
|
||||
|
||||
@ -130,6 +151,77 @@ function Test-PSWindowsUpdate {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AllUpdateCategories {
|
||||
param([switch]$Automated)
|
||||
|
||||
try {
|
||||
# Use Windows Update COM API
|
||||
$updateSession = New-Object -ComObject Microsoft.Update.Session
|
||||
$updateSearcher = $updateSession.CreateUpdateSearcher()
|
||||
|
||||
# Set to search Microsoft Update (includes more categories)
|
||||
$microsoftUpdateServiceId = "7971f918-a847-4430-9279-4a52d1efe18d"
|
||||
try {
|
||||
$updateSearcher.ServiceID = $microsoftUpdateServiceId
|
||||
$updateSearcher.ServerSelection = 3 # ssOthers
|
||||
}
|
||||
catch {
|
||||
# Fall back to default
|
||||
}
|
||||
|
||||
$categories = @{}
|
||||
|
||||
# Method 1: Search for pending and installed updates
|
||||
$searches = @("IsInstalled=0", "IsInstalled=1 and IsHidden=0")
|
||||
|
||||
foreach ($searchCriteria in $searches) {
|
||||
try {
|
||||
$searchResult = $updateSearcher.Search($searchCriteria)
|
||||
foreach ($update in $searchResult.Updates) {
|
||||
foreach ($cat in $update.Categories) {
|
||||
# Filter by Type = "UpdateClassification" (excludes product names)
|
||||
if ($cat.Name -and $cat.Type -eq "UpdateClassification") {
|
||||
if (-not $categories.ContainsKey($cat.Name)) {
|
||||
$categories[$cat.Name] = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# Continue with next search
|
||||
}
|
||||
}
|
||||
|
||||
# Method 2: Also check update history for more categories
|
||||
try {
|
||||
$historyCount = $updateSearcher.GetTotalHistoryCount()
|
||||
if ($historyCount -gt 0) {
|
||||
$history = $updateSearcher.QueryHistory(0, [Math]::Min($historyCount, 200))
|
||||
foreach ($entry in $history) {
|
||||
if ($entry.Categories) {
|
||||
foreach ($cat in $entry.Categories) {
|
||||
if ($cat.Name -and $cat.Type -eq "UpdateClassification") {
|
||||
if (-not $categories.ContainsKey($cat.Name)) {
|
||||
$categories[$cat.Name] = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# History query failed, continue with what we have
|
||||
}
|
||||
|
||||
return $categories
|
||||
}
|
||||
catch {
|
||||
return @{}
|
||||
}
|
||||
}
|
||||
|
||||
function Test-PreUpdateChecks {
|
||||
param([switch]$Automated)
|
||||
|
||||
@ -178,6 +270,54 @@ function Test-PreUpdateChecks {
|
||||
return $true
|
||||
}
|
||||
|
||||
function Update-DefenderSignatures {
|
||||
param([switch]$Automated)
|
||||
|
||||
Write-Log "Updating Microsoft Defender signatures..." -Level Info -Automated:$Automated
|
||||
|
||||
try {
|
||||
# Get current signature version before update
|
||||
$defenderStatus = Get-MpComputerStatus -ErrorAction SilentlyContinue
|
||||
$beforeVersion = if ($defenderStatus) { $defenderStatus.AntivirusSignatureVersion } else { "Unknown" }
|
||||
|
||||
# Update signatures using built-in cmdlet (more reliable than Windows Update)
|
||||
Update-MpSignature -ErrorAction Stop
|
||||
|
||||
# Get new version
|
||||
$defenderStatus = Get-MpComputerStatus -ErrorAction SilentlyContinue
|
||||
$afterVersion = if ($defenderStatus) { $defenderStatus.AntivirusSignatureVersion } else { "Unknown" }
|
||||
|
||||
if ($beforeVersion -ne $afterVersion) {
|
||||
Write-Log "Defender signatures updated: $beforeVersion -> $afterVersion" -Level Success -Automated:$Automated
|
||||
$script:UpdateStats.Installed++
|
||||
}
|
||||
else {
|
||||
Write-Log "Defender signatures already up to date (Version: $afterVersion)" -Level Info -Automated:$Automated
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Write-Log "Failed to update Defender signatures: $_" -Level Warning -Automated:$Automated
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WindowsUpdateRescan {
|
||||
param([switch]$Automated)
|
||||
|
||||
Write-Log "Refreshing Windows Update UI..." -Level Info -Automated:$Automated
|
||||
|
||||
try {
|
||||
# startinteractivescan refreshes the Windows Update GUI to reflect actual installed state
|
||||
$null = Start-Process -FilePath "UsoClient.exe" -ArgumentList "startinteractivescan" -Wait -NoNewWindow -PassThru -ErrorAction SilentlyContinue
|
||||
Write-Log "Windows Update UI refreshed" -Level Success -Automated:$Automated
|
||||
}
|
||||
catch {
|
||||
Write-Log "Could not refresh Windows Update UI: $_" -Level Warning -Automated:$Automated
|
||||
}
|
||||
}
|
||||
|
||||
function Get-AvailableUpdates {
|
||||
param([switch]$Automated)
|
||||
|
||||
@ -189,11 +329,21 @@ function Get-AvailableUpdates {
|
||||
$update = $_
|
||||
$included = $false
|
||||
|
||||
# Check categories
|
||||
foreach ($cat in $UpdateCategories) {
|
||||
if ($update.Categories -match $cat) {
|
||||
$included = $true
|
||||
break
|
||||
# Check categories - if "all", include everything; otherwise filter by category list
|
||||
if ($InstallAllCategories) {
|
||||
$included = $true
|
||||
}
|
||||
else {
|
||||
# Categories is a collection of Category objects with Name property
|
||||
foreach ($cat in $UpdateCategories) {
|
||||
foreach ($updateCategory in $update.Categories) {
|
||||
$categoryName = if ($updateCategory.Name) { $updateCategory.Name } else { $updateCategory.ToString() }
|
||||
if ($categoryName -match $cat) {
|
||||
$included = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($included) { break }
|
||||
}
|
||||
}
|
||||
|
||||
@ -219,6 +369,12 @@ function Get-AvailableUpdates {
|
||||
}
|
||||
}
|
||||
|
||||
# Exclude Defender signature updates (handled separately via Update-MpSignature)
|
||||
if ($included -and $update.Title -like "*Security Intelligence Update for Microsoft Defender*") {
|
||||
Write-Log "Skipping (handled separately): $($update.Title)" -Level Info -Automated:$Automated
|
||||
$included = $false
|
||||
}
|
||||
|
||||
return $included
|
||||
}
|
||||
|
||||
@ -246,7 +402,8 @@ function Install-AvailableUpdates {
|
||||
|
||||
foreach ($update in $Updates) {
|
||||
$sizeKB = [math]::Round($update.Size / 1KB, 2)
|
||||
Write-Log " [$($update.KBArticleIDs -join ',')] $($update.Title) ($sizeKB KB)" -Level Info -Automated:$Automated
|
||||
$kbDisplay = if ($update.KBArticleIDs) { $update.KBArticleIDs -join ',' } else { "N/A" }
|
||||
Write-Log " [$kbDisplay] $($update.Title) ($sizeKB KB)" -Level Info -Automated:$Automated
|
||||
}
|
||||
|
||||
Write-Log "========================================" -Level Info -Automated:$Automated
|
||||
@ -257,24 +414,18 @@ function Install-AvailableUpdates {
|
||||
return
|
||||
}
|
||||
|
||||
# Install updates
|
||||
# Install updates - pipe directly to preserve update selection
|
||||
Write-Log "Installing updates..." -Level Info -Automated:$Automated
|
||||
|
||||
try {
|
||||
$installParams = @{
|
||||
MicrosoftUpdate = $true
|
||||
AcceptAll = $true
|
||||
IgnoreReboot = ($Options.rebootBehavior -ne 'auto')
|
||||
Verbose = $false
|
||||
}
|
||||
|
||||
# Use KBArticleID filter if available
|
||||
$kbList = $Updates | ForEach-Object { $_.KBArticleIDs } | Where-Object { $_ }
|
||||
if ($kbList.Count -gt 0) {
|
||||
$installParams['KBArticleID'] = $kbList
|
||||
}
|
||||
|
||||
$result = Install-WindowsUpdate @installParams
|
||||
# Pipe updates directly to Install-WindowsUpdate for reliable installation
|
||||
$result = $Updates | Install-WindowsUpdate @installParams
|
||||
|
||||
# Process results
|
||||
foreach ($item in $result) {
|
||||
@ -417,6 +568,21 @@ function Start-BusinessLogic {
|
||||
Write-Log "========================================" -Level Info -Automated:$Automated
|
||||
Write-Log "Windows Update Process Started" -Level Info -Automated:$Automated
|
||||
Write-Log "Script Version: $ScriptVersion ($ScriptDate)" -Level Info -Automated:$Automated
|
||||
if ($InstallAllCategories) {
|
||||
Write-Log "Update Categories: ALL" -Level Info -Automated:$Automated
|
||||
# Query and display all known categories from Windows Update
|
||||
$knownCategories = Get-AllUpdateCategories -Automated:$Automated
|
||||
if ($knownCategories.Count -gt 0) {
|
||||
$categoryList = ($knownCategories.Keys | Sort-Object) -join ', '
|
||||
Write-Log " Available: $categoryList" -Level Info -Automated:$Automated
|
||||
}
|
||||
else {
|
||||
Write-Log " Available: (unable to query)" -Level Info -Automated:$Automated
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Log "Update Categories: $($UpdateCategories -join ', ')" -Level Info -Automated:$Automated
|
||||
}
|
||||
if ($DryRun) {
|
||||
Write-Log "DRY RUN MODE - No changes will be made" -Level Warning -Automated:$Automated
|
||||
}
|
||||
@ -434,6 +600,14 @@ function Start-BusinessLogic {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Update Microsoft Defender signatures first (more reliable than Windows Update)
|
||||
if (-not $DryRun) {
|
||||
$null = Update-DefenderSignatures -Automated:$Automated
|
||||
}
|
||||
else {
|
||||
Write-Log "DRY RUN: Skipping Defender signature update" -Level Info -Automated:$Automated
|
||||
}
|
||||
|
||||
# Scan for updates
|
||||
$updates = Get-AvailableUpdates -Automated:$Automated
|
||||
|
||||
@ -448,6 +622,11 @@ function Start-BusinessLogic {
|
||||
Invoke-PostUpdateActions -Automated:$Automated
|
||||
}
|
||||
|
||||
# Refresh Windows Update cache (clears stale pending updates from UI)
|
||||
if (-not $DryRun) {
|
||||
Invoke-WindowsUpdateRescan -Automated:$Automated
|
||||
}
|
||||
|
||||
# Print summary
|
||||
Write-UpdateSummary -Automated:$Automated
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
|
||||
REM Change directory to the location of the script
|
||||
cd /d %~dp0
|
||||
|
||||
REM Run Force Amend Tagged Commit script
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0Force-AmendTaggedCommit.ps1"
|
||||
|
||||
pause
|
||||
242
utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
Normal file
242
utils/Force-AmendTaggedCommit/Force-AmendTaggedCommit.ps1
Normal file
@ -0,0 +1,242 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Amends the latest commit, recreates its associated tag, and force pushes both to remote.
|
||||
|
||||
.DESCRIPTION
|
||||
This script performs the following operations:
|
||||
1. Gets the last commit and verifies it has an associated tag
|
||||
2. Stages all pending changes
|
||||
3. Amends the latest commit (keeps existing message)
|
||||
4. Deletes and recreates the tag on the amended commit
|
||||
5. Force pushes the branch and tag to remote
|
||||
|
||||
All configuration is in scriptsettings.json.
|
||||
|
||||
.PARAMETER DryRun
|
||||
If specified, shows what would be done without making changes.
|
||||
|
||||
.EXAMPLE
|
||||
.\Force-AmendTaggedCommit.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Force-AmendTaggedCommit.ps1 -DryRun
|
||||
|
||||
.NOTES
|
||||
CONFIGURATION (scriptsettings.json):
|
||||
- git.remote: Remote name to push to (default: "origin")
|
||||
- git.confirmBeforeAmend: Prompt before amending (default: true)
|
||||
- git.confirmWhenNoChanges: Prompt if no pending changes (default: true)
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# ==============================================================================
|
||||
# PATH CONFIGURATION
|
||||
# ==============================================================================
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
# ==============================================================================
|
||||
# LOAD SETTINGS
|
||||
# ==============================================================================
|
||||
|
||||
$settingsPath = Join-Path $scriptDir "scriptsettings.json"
|
||||
if (-not (Test-Path $settingsPath)) {
|
||||
Write-Error "Settings file not found: $settingsPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
|
||||
|
||||
# Extract settings with defaults
|
||||
$Remote = if ($settings.git.remote) { $settings.git.remote } else { "origin" }
|
||||
$ConfirmBeforeAmend = if ($null -ne $settings.git.confirmBeforeAmend) { $settings.git.confirmBeforeAmend } else { $true }
|
||||
$ConfirmWhenNoChanges = if ($null -ne $settings.git.confirmWhenNoChanges) { $settings.git.confirmWhenNoChanges } else { $true }
|
||||
|
||||
# ==============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# ==============================================================================
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Text)
|
||||
Write-Host "`n>> $Text" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
param([string]$Text)
|
||||
Write-Host " $Text" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Text)
|
||||
Write-Host " $Text" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
function Write-Warn {
|
||||
param([string]$Text)
|
||||
Write-Host " $Text" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
function Invoke-Git {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Arguments,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$CaptureOutput,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ErrorMessage = "Git command failed"
|
||||
)
|
||||
|
||||
if ($CaptureOutput) {
|
||||
$output = & git @Arguments 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
throw "$ErrorMessage (exit code: $exitCode)"
|
||||
}
|
||||
return $output
|
||||
} else {
|
||||
& git @Arguments
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
throw "$ErrorMessage (exit code: $exitCode)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ==============================================================================
|
||||
# MAIN EXECUTION
|
||||
# ==============================================================================
|
||||
|
||||
try {
|
||||
Write-Host "`n========================================" -ForegroundColor Magenta
|
||||
Write-Host " Force Amend Tagged Commit Script" -ForegroundColor Magenta
|
||||
Write-Host "========================================`n" -ForegroundColor Magenta
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Warn "*** DRY RUN MODE - No changes will be made ***`n"
|
||||
}
|
||||
|
||||
# Get current branch
|
||||
Write-Step "Getting current branch..."
|
||||
$Branch = Invoke-Git -Arguments @("rev-parse", "--abbrev-ref", "HEAD") -CaptureOutput -ErrorMessage "Failed to get current branch"
|
||||
Write-Info "Branch: $Branch"
|
||||
|
||||
# Get last commit info
|
||||
Write-Step "Getting last commit..."
|
||||
$null = Invoke-Git -Arguments @("rev-parse", "HEAD") -CaptureOutput -ErrorMessage "Failed to get HEAD commit"
|
||||
$CommitMessage = Invoke-Git -Arguments @("log", "-1", "--format=%s") -CaptureOutput
|
||||
$CommitHash = Invoke-Git -Arguments @("log", "-1", "--format=%h") -CaptureOutput
|
||||
Write-Info "Commit: $CommitHash - $CommitMessage"
|
||||
|
||||
# Find tag pointing to HEAD
|
||||
Write-Step "Finding tag on last commit..."
|
||||
$Tags = & git tag --points-at HEAD 2>&1
|
||||
|
||||
if (-not $Tags -or [string]::IsNullOrWhiteSpace("$Tags")) {
|
||||
throw "No tag found on the last commit ($CommitHash). This script requires the last commit to have an associated tag."
|
||||
}
|
||||
|
||||
# If multiple tags, use the first one
|
||||
$TagName = ("$Tags" -split "`n")[0].Trim()
|
||||
Write-Success "Found tag: $TagName"
|
||||
|
||||
# Show current status
|
||||
Write-Step "Checking pending changes..."
|
||||
$Status = & git status --short 2>&1
|
||||
if ($Status -and -not [string]::IsNullOrWhiteSpace("$Status")) {
|
||||
Write-Info "Pending changes:"
|
||||
"$Status" -split "`n" | ForEach-Object { Write-Info " $_" }
|
||||
} else {
|
||||
Write-Warn "No pending changes found"
|
||||
if ($ConfirmWhenNoChanges -and -not $DryRun) {
|
||||
$confirm = Read-Host "`n No changes to amend. Continue to recreate tag and force push? (y/N)"
|
||||
if ($confirm -ne 'y' -and $confirm -ne 'Y') {
|
||||
Write-Host "`nAborted by user" -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Confirm operation
|
||||
Write-Host "`n----------------------------------------" -ForegroundColor White
|
||||
Write-Host " Summary of operations:" -ForegroundColor White
|
||||
Write-Host "----------------------------------------" -ForegroundColor White
|
||||
Write-Host " Branch: $Branch" -ForegroundColor White
|
||||
Write-Host " Commit: $CommitHash" -ForegroundColor White
|
||||
Write-Host " Tag: $TagName" -ForegroundColor White
|
||||
Write-Host " Remote: $Remote" -ForegroundColor White
|
||||
Write-Host "----------------------------------------`n" -ForegroundColor White
|
||||
|
||||
if ($ConfirmBeforeAmend -and -not $DryRun) {
|
||||
$confirm = Read-Host " Proceed with amend and force push? (y/N)"
|
||||
if ($confirm -ne 'y' -and $confirm -ne 'Y') {
|
||||
Write-Host "`nAborted by user" -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
# Stage all changes
|
||||
Write-Step "Staging all changes..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("add", "-A") -ErrorMessage "Failed to stage changes"
|
||||
}
|
||||
Write-Success "All changes staged"
|
||||
|
||||
# Amend commit
|
||||
Write-Step "Amending commit..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("commit", "--amend", "--no-edit") -ErrorMessage "Failed to amend commit"
|
||||
}
|
||||
Write-Success "Commit amended"
|
||||
|
||||
# Delete local tag
|
||||
Write-Step "Deleting local tag '$TagName'..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("tag", "-d", $TagName) -ErrorMessage "Failed to delete local tag"
|
||||
}
|
||||
Write-Success "Local tag deleted"
|
||||
|
||||
# Recreate tag on new commit
|
||||
Write-Step "Recreating tag '$TagName' on amended commit..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("tag", $TagName) -ErrorMessage "Failed to create tag"
|
||||
}
|
||||
Write-Success "Tag recreated"
|
||||
|
||||
# Force push branch
|
||||
Write-Step "Force pushing branch '$Branch' to $Remote..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("push", "--force", $Remote, $Branch) -ErrorMessage "Failed to force push branch"
|
||||
}
|
||||
Write-Success "Branch force pushed"
|
||||
|
||||
# Force push tag
|
||||
Write-Step "Force pushing tag '$TagName' to $Remote..."
|
||||
if (-not $DryRun) {
|
||||
Invoke-Git -Arguments @("push", "--force", $Remote, $TagName) -ErrorMessage "Failed to force push tag"
|
||||
}
|
||||
Write-Success "Tag force pushed"
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Green
|
||||
Write-Host " Operation completed successfully!" -ForegroundColor Green
|
||||
Write-Host "========================================`n" -ForegroundColor Green
|
||||
|
||||
# Show final state
|
||||
Write-Host "Final state:" -ForegroundColor White
|
||||
& git log -1 --oneline
|
||||
Write-Host ""
|
||||
|
||||
} catch {
|
||||
Write-Host "`n========================================" -ForegroundColor Red
|
||||
Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host "========================================`n" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
10
utils/Force-AmendTaggedCommit/scriptsettings.json
Normal file
10
utils/Force-AmendTaggedCommit/scriptsettings.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$comment": "Configuration for Force-AmendTaggedCommit.ps1",
|
||||
|
||||
"git": {
|
||||
"remote": "origin",
|
||||
"confirmBeforeAmend": true,
|
||||
"confirmWhenNoChanges": true
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
REM Generate-CoverageBadges.bat - Wrapper for Generate-CoverageBadges.ps1
|
||||
REM Runs tests and generates SVG coverage badges for README
|
||||
|
||||
pushd "%~dp0"
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Generate-CoverageBadges.ps1" %*
|
||||
set EXITCODE=%ERRORLEVEL%
|
||||
popd
|
||||
exit /b %EXITCODE%
|
||||
198
utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
Normal file
198
utils/Generate-CoverageBadges/Generate-CoverageBadges.ps1
Normal file
@ -0,0 +1,198 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Generates SVG coverage badges for README.
|
||||
|
||||
.DESCRIPTION
|
||||
This script runs unit tests via TestRunner.psm1, then generates shields.io-style
|
||||
SVG badges for line, branch, and method coverage.
|
||||
|
||||
Configuration is stored in scriptsettings.json:
|
||||
- paths.testProject : Relative path to test project
|
||||
- paths.badgesDir : Relative path to badges output directory
|
||||
- badges : Array of badges to generate (name, label, metric)
|
||||
- colorThresholds : Coverage percentages for badge colors
|
||||
|
||||
Badge colors based on coverage:
|
||||
- brightgreen (>=80%), green (>=60%), yellowgreen (>=40%)
|
||||
- yellow (>=20%), orange (>=10%), red (<10%)
|
||||
|
||||
.PARAMETER OpenReport
|
||||
Generate and open a full HTML coverage report in the default browser.
|
||||
Requires ReportGenerator: dotnet tool install -g dotnet-reportgenerator-globaltool
|
||||
|
||||
.EXAMPLE
|
||||
.\Generate-CoverageBadges.ps1
|
||||
Runs tests and generates coverage badges.
|
||||
|
||||
.EXAMPLE
|
||||
.\Generate-CoverageBadges.ps1 -OpenReport
|
||||
Runs tests, generates badges, and opens HTML report in browser.
|
||||
|
||||
.OUTPUTS
|
||||
SVG badge files in the configured badges directory.
|
||||
|
||||
.NOTES
|
||||
Author: MaksIT
|
||||
Requires: .NET SDK, Coverlet (included in test project)
|
||||
#>
|
||||
param(
|
||||
[switch]$OpenReport
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptDir = $PSScriptRoot
|
||||
|
||||
# Import TestRunner module
|
||||
$ModulePath = Join-Path (Split-Path $ScriptDir -Parent) "TestRunner.psm1"
|
||||
if (-not (Test-Path $ModulePath)) {
|
||||
Write-Host "TestRunner module not found at: $ModulePath" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Import-Module $ModulePath -Force
|
||||
|
||||
# Load settings
|
||||
$SettingsFile = Join-Path $ScriptDir "scriptsettings.json"
|
||||
if (-not (Test-Path $SettingsFile)) {
|
||||
Write-Host "Settings file not found: $SettingsFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$Settings = Get-Content $SettingsFile | ConvertFrom-Json
|
||||
|
||||
# Resolve paths from settings
|
||||
$TestProjectPath = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.testProject))
|
||||
$BadgesDir = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $Settings.paths.badgesDir))
|
||||
|
||||
# Ensure badges directory exists
|
||||
if (-not (Test-Path $BadgesDir)) {
|
||||
New-Item -ItemType Directory -Path $BadgesDir | Out-Null
|
||||
}
|
||||
|
||||
# Run tests with coverage
|
||||
$coverage = Invoke-TestsWithCoverage -TestProjectPath $TestProjectPath -KeepResults:$OpenReport
|
||||
|
||||
if (-not $coverage.Success) {
|
||||
Write-Host "Tests failed: $($coverage.Error)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Tests passed!" -ForegroundColor Green
|
||||
|
||||
# Store metrics in a hashtable for easy lookup
|
||||
$metrics = @{
|
||||
"line" = $coverage.LineRate
|
||||
"branch" = $coverage.BranchRate
|
||||
"method" = $coverage.MethodRate
|
||||
}
|
||||
|
||||
# Function to get badge color based on coverage percentage and thresholds from settings
|
||||
function Get-BadgeColor {
|
||||
param([double]$percentage)
|
||||
|
||||
$thresholds = $Settings.colorThresholds
|
||||
|
||||
if ($percentage -ge $thresholds.brightgreen) { return "brightgreen" }
|
||||
if ($percentage -ge $thresholds.green) { return "green" }
|
||||
if ($percentage -ge $thresholds.yellowgreen) { return "yellowgreen" }
|
||||
if ($percentage -ge $thresholds.yellow) { return "yellow" }
|
||||
if ($percentage -ge $thresholds.orange) { return "orange" }
|
||||
return "red"
|
||||
}
|
||||
|
||||
# Function to create shields.io style SVG badge
|
||||
function New-Badge {
|
||||
param(
|
||||
[string]$label,
|
||||
[string]$value,
|
||||
[string]$color
|
||||
)
|
||||
|
||||
# Calculate widths (approximate character width of 6.5px for the font)
|
||||
$labelWidth = [math]::Max(($label.Length * 6.5) + 10, 50)
|
||||
$valueWidth = [math]::Max(($value.Length * 6.5) + 10, 40)
|
||||
$totalWidth = $labelWidth + $valueWidth
|
||||
$labelX = $labelWidth / 2
|
||||
$valueX = $labelWidth + ($valueWidth / 2)
|
||||
|
||||
$colorMap = @{
|
||||
"brightgreen" = "#4c1"
|
||||
"green" = "#97ca00"
|
||||
"yellowgreen" = "#a4a61d"
|
||||
"yellow" = "#dfb317"
|
||||
"orange" = "#fe7d37"
|
||||
"red" = "#e05d44"
|
||||
}
|
||||
$hexColor = $colorMap[$color]
|
||||
if (-not $hexColor) { $hexColor = "#9f9f9f" }
|
||||
|
||||
return @"
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="$totalWidth" height="20" role="img" aria-label="$label`: $value">
|
||||
<title>$label`: $value</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r">
|
||||
<rect width="$totalWidth" height="20" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="$labelWidth" height="20" fill="#555"/>
|
||||
<rect x="$labelWidth" width="$valueWidth" height="20" fill="$hexColor"/>
|
||||
<rect width="$totalWidth" 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="$labelX" y="15" fill="#010101" fill-opacity=".3">$label</text>
|
||||
<text x="$labelX" y="14" fill="#fff">$label</text>
|
||||
<text aria-hidden="true" x="$valueX" y="15" fill="#010101" fill-opacity=".3">$value</text>
|
||||
<text x="$valueX" y="14" fill="#fff">$value</text>
|
||||
</g>
|
||||
</svg>
|
||||
"@
|
||||
}
|
||||
|
||||
# Generate badges from settings
|
||||
Write-Host "Generating coverage badges..." -ForegroundColor Cyan
|
||||
|
||||
foreach ($badge in $Settings.badges) {
|
||||
$metricValue = $metrics[$badge.metric]
|
||||
$color = Get-BadgeColor $metricValue
|
||||
$svg = New-Badge -label $badge.label -value "$metricValue%" -color $color
|
||||
$path = Join-Path $BadgesDir $badge.name
|
||||
$svg | Out-File -FilePath $path -Encoding utf8
|
||||
Write-Host " $($badge.name): $($badge.label) = $metricValue%" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Display summary
|
||||
Write-Host ""
|
||||
Write-Host "=== Coverage Summary ===" -ForegroundColor Yellow
|
||||
Write-Host " Line Coverage: $($coverage.LineRate)%"
|
||||
Write-Host " Branch Coverage: $($coverage.BranchRate)%"
|
||||
Write-Host " Method Coverage: $($coverage.MethodRate)% ($($coverage.CoveredMethods) of $($coverage.TotalMethods) methods)"
|
||||
Write-Host "========================" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "Badges generated in: $BadgesDir" -ForegroundColor Green
|
||||
Write-Host "Commit the badges/ folder to update README." -ForegroundColor Cyan
|
||||
|
||||
# Optionally generate full HTML report
|
||||
if ($OpenReport -and $coverage.CoverageFile) {
|
||||
Write-Host ""
|
||||
Write-Host "Generating HTML report..." -ForegroundColor Cyan
|
||||
|
||||
$ResultsDir = Split-Path (Split-Path $coverage.CoverageFile -Parent) -Parent
|
||||
$ReportDir = Join-Path $ResultsDir "report"
|
||||
|
||||
$reportGenArgs = @(
|
||||
"-reports:$($coverage.CoverageFile)"
|
||||
"-targetdir:$ReportDir"
|
||||
"-reporttypes:Html"
|
||||
)
|
||||
& reportgenerator @reportGenArgs
|
||||
|
||||
$IndexFile = Join-Path $ReportDir "index.html"
|
||||
if (Test-Path $IndexFile) {
|
||||
Start-Process $IndexFile
|
||||
}
|
||||
|
||||
Write-Host "TestResults kept for HTML report viewing." -ForegroundColor Gray
|
||||
}
|
||||
34
utils/Generate-CoverageBadges/scriptsettings.json
Normal file
34
utils/Generate-CoverageBadges/scriptsettings.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema",
|
||||
"title": "Generate Coverage Badges Script Settings",
|
||||
"description": "Configuration for Generate-CoverageBadges.ps1 script",
|
||||
"paths": {
|
||||
"testProject": "..\\..\\src\\MaksIT.UScheduler.Tests",
|
||||
"badgesDir": "..\\..\\badges"
|
||||
},
|
||||
"badges": [
|
||||
{
|
||||
"name": "coverage-lines.svg",
|
||||
"label": "Line Coverage",
|
||||
"metric": "line"
|
||||
},
|
||||
{
|
||||
"name": "coverage-branches.svg",
|
||||
"label": "Branch Coverage",
|
||||
"metric": "branch"
|
||||
},
|
||||
{
|
||||
"name": "coverage-methods.svg",
|
||||
"label": "Method Coverage",
|
||||
"metric": "method"
|
||||
}
|
||||
],
|
||||
"colorThresholds": {
|
||||
"brightgreen": 80,
|
||||
"green": 60,
|
||||
"yellowgreen": 40,
|
||||
"yellow": 20,
|
||||
"orange": 10,
|
||||
"red": 0
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
REM Change directory to the location of the script
|
||||
cd /d %~dp0
|
||||
|
||||
REM Invoke the PowerShell script (Release-ToGitHub.ps1) in the same directory
|
||||
REM Run GitHub release script
|
||||
powershell -ExecutionPolicy Bypass -File "%~dp0Release-ToGitHub.ps1"
|
||||
|
||||
pause
|
||||
695
utils/Release-ToGitHub/Release-ToGitHub.ps1
Normal file
695
utils/Release-ToGitHub/Release-ToGitHub.ps1
Normal file
@ -0,0 +1,695 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Automated GitHub release script for MaksIT.UScheduler.
|
||||
|
||||
.DESCRIPTION
|
||||
Creates a GitHub release by performing the following steps:
|
||||
|
||||
Pre-flight checks:
|
||||
- Detects current branch (main or dev)
|
||||
- On main: requires clean working directory; on dev: uncommitted changes allowed
|
||||
- Reads version from .csproj (source of truth)
|
||||
- On main: requires matching tag (vX.Y.Z format)
|
||||
- Ensures version consistency with CHANGELOG.md
|
||||
- Confirms GitHub CLI authentication via GH_TOKEN (main branch only)
|
||||
|
||||
Test execution:
|
||||
- Runs all unit tests via Run-Tests.ps1
|
||||
- Aborts release if any tests fail
|
||||
- Displays coverage summary (line, branch, method)
|
||||
|
||||
Build and release:
|
||||
- Publishes the .NET project in Release configuration
|
||||
- Copies Scripts folder into the release
|
||||
- Creates a versioned ZIP archive
|
||||
- Extracts release notes from CHANGELOG.md
|
||||
- Pushes tag to remote if not already present (main branch only)
|
||||
- Creates (or recreates) the GitHub release with assets (main branch only)
|
||||
|
||||
Branch-based behavior (configurable in scriptsettings.json):
|
||||
- On dev branch: Local build only, no tag required, uncommitted changes allowed
|
||||
- On release branch: Full GitHub release, tag required, clean working directory required
|
||||
- On other branches: Blocked
|
||||
|
||||
.NOTES
|
||||
File: Release-ToGitHub.ps1
|
||||
Author: Maksym Sadovnychyy (MAKS-IT)
|
||||
Requires: dotnet, git, gh (GitHub CLI - required on main branch only)
|
||||
|
||||
Configuration is loaded from scriptsettings.json in the same directory.
|
||||
Set the GitHub token in an environment variable specified by github.tokenEnvVar.
|
||||
|
||||
.EXAMPLE
|
||||
.\Release-ToGitHub.ps1
|
||||
|
||||
Runs the release process using settings from scriptsettings.json.
|
||||
On dev branch: creates local build (no tag needed).
|
||||
On main branch: publishes to GitHub (tag required).
|
||||
|
||||
.EXAMPLE
|
||||
# Recommended workflow:
|
||||
# 1. On dev branch: Update version in .csproj and CHANGELOG.md
|
||||
# 2. Commit changes
|
||||
# 3. Run: .\Release-ToGitHub.ps1
|
||||
# (creates local build for testing - no tag needed)
|
||||
# 4. Test the build
|
||||
# 5. Merge to main: git checkout main && git merge dev
|
||||
# 6. Create tag: git tag v1.0.1
|
||||
# 7. Run: .\Release-ToGitHub.ps1
|
||||
# (publishes to GitHub)
|
||||
#>
|
||||
|
||||
# No parameters - behavior is controlled by current branch (configured in scriptsettings.json):
|
||||
# - dev branch -> Local build only (no tag required, uncommitted changes allowed)
|
||||
# - release branch -> Full release to GitHub (tag required, clean working directory)
|
||||
|
||||
# Load settings from scriptsettings.json
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$settingsPath = Join-Path $scriptDir "scriptsettings.json"
|
||||
|
||||
if (-not (Test-Path $settingsPath)) {
|
||||
Write-Error "Settings file not found: $settingsPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
|
||||
|
||||
# Import TestRunner module
|
||||
$modulePath = Join-Path (Split-Path $scriptDir -Parent) "TestRunner.psm1"
|
||||
if (-not (Test-Path $modulePath)) {
|
||||
Write-Error "TestRunner module not found at: $modulePath"
|
||||
exit 1
|
||||
}
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
# Set GH_TOKEN from custom environment variable for GitHub CLI authentication
|
||||
$tokenEnvVar = $settings.github.tokenEnvVar
|
||||
$env:GH_TOKEN = [System.Environment]::GetEnvironmentVariable($tokenEnvVar)
|
||||
|
||||
# Paths from settings (resolve relative to script directory)
|
||||
$csprojPaths = @()
|
||||
if ($settings.paths.csprojPath -is [System.Collections.IEnumerable] -and -not ($settings.paths.csprojPath -is [string])) {
|
||||
foreach ($path in $settings.paths.csprojPath) {
|
||||
$csprojPaths += [System.IO.Path]::GetFullPath((Join-Path $scriptDir $path))
|
||||
}
|
||||
}
|
||||
else {
|
||||
$csprojPaths += [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.csprojPath))
|
||||
}
|
||||
|
||||
$stagingDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.stagingDir))
|
||||
$releaseDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.releaseDir))
|
||||
$changelogPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.changelogPath))
|
||||
$scriptsPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.scriptsPath))
|
||||
$testProjectPath = [System.IO.Path]::GetFullPath((Join-Path $scriptDir $settings.paths.testProject))
|
||||
|
||||
# Release naming patterns
|
||||
$zipNamePattern = $settings.release.zipNamePattern
|
||||
$releaseTitlePattern = $settings.release.releaseTitlePattern
|
||||
|
||||
# Branch configuration
|
||||
$releaseBranch = $settings.branches.release
|
||||
$devBranch = $settings.branches.dev
|
||||
|
||||
# Project configuration (avoid hardcoding project names)
|
||||
$projectsSettings = $settings.projects
|
||||
$scheduleManagerCsprojEndsWith = $projectsSettings.scheduleManagerCsprojEndsWith
|
||||
$uschedulerCsprojEndsWith = $projectsSettings.uschedulerCsprojEndsWith
|
||||
$scheduleManagerAppSettingsFile = $projectsSettings.scheduleManagerAppSettingsFile
|
||||
$uschedulerAppSettingsFile = $projectsSettings.uschedulerAppSettingsFile
|
||||
$scheduleManagerServiceBinPath = $projectsSettings.scheduleManagerServiceBinPath
|
||||
$uschedulerLogDir = $projectsSettings.uschedulerLogDir
|
||||
$scriptsRelativeToExe = $projectsSettings.scriptsRelativeToExe
|
||||
|
||||
# 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
|
||||
}
|
||||
}
|
||||
|
||||
# Helper: extract a csproj property (first match)
|
||||
function Get-CsprojPropertyValue {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][xml]$csproj,
|
||||
[Parameter(Mandatory=$true)][string]$propertyName
|
||||
)
|
||||
|
||||
$propNode = $csproj.Project.PropertyGroup |
|
||||
Where-Object { $_.$propertyName } |
|
||||
Select-Object -First 1
|
||||
|
||||
if ($propNode) {
|
||||
return $propNode.$propertyName
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# Helper: resolve output assembly name for published exe
|
||||
function Resolve-ProjectExeName {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$projPath
|
||||
)
|
||||
|
||||
[xml]$csproj = Get-Content $projPath
|
||||
$assemblyName = Get-CsprojPropertyValue -csproj $csproj -propertyName "AssemblyName"
|
||||
if ($assemblyName) {
|
||||
return $assemblyName
|
||||
}
|
||||
|
||||
return [System.IO.Path]::GetFileNameWithoutExtension($projPath)
|
||||
}
|
||||
|
||||
# Helper: find csproj by configured suffix
|
||||
function Find-CsprojByEndsWith {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string[]]$paths,
|
||||
[Parameter(Mandatory=$true)][string]$endsWith
|
||||
)
|
||||
|
||||
if (-not $endsWith) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $paths | Where-Object { $_ -like "*$endsWith" } | Select-Object -First 1
|
||||
}
|
||||
|
||||
Assert-Command dotnet
|
||||
Assert-Command git
|
||||
# gh command check deferred until after branch detection (only needed on main branch)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==================================================" -ForegroundColor Cyan
|
||||
Write-Host "RELEASE BUILD" -ForegroundColor Cyan
|
||||
Write-Host "==================================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# ==============================================================================
|
||||
# PRE-FLIGHT CHECKS
|
||||
# ==============================================================================
|
||||
|
||||
# 1. Detect current branch and determine release mode
|
||||
Write-Host "Detecting current branch..." -ForegroundColor Gray
|
||||
$currentBranch = git rev-parse --abbrev-ref HEAD 2>$null
|
||||
if ($LASTEXITCODE -ne 0 -or -not $currentBranch) {
|
||||
Write-Error "Could not determine current branch."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$currentBranch = $currentBranch.Trim()
|
||||
Write-Host " Branch: $currentBranch" -ForegroundColor Green
|
||||
|
||||
$isDevBranch = $currentBranch -eq $devBranch
|
||||
$isReleaseBranch = $currentBranch -eq $releaseBranch
|
||||
|
||||
if (-not $isDevBranch -and -not $isReleaseBranch) {
|
||||
Write-Error "Releases can only be created from '$releaseBranch' or '$devBranch' branches. Current branch: $currentBranch"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($isDevBranch) {
|
||||
Write-Host " Dev branch ($devBranch) - local build only (no GitHub release)." -ForegroundColor Yellow
|
||||
}
|
||||
else {
|
||||
Write-Host " Release branch ($releaseBranch) - will publish to GitHub." -ForegroundColor Cyan
|
||||
Assert-Command gh
|
||||
}
|
||||
|
||||
# 2. Check for uncommitted changes (required on main, allowed on dev)
|
||||
$gitStatus = git status --porcelain 2>$null
|
||||
if ($gitStatus) {
|
||||
if ($isReleaseBranch) {
|
||||
Write-Error "Working directory has uncommitted changes. Commit or stash them before releasing."
|
||||
Write-Host ""
|
||||
Write-Host "Uncommitted files:" -ForegroundColor Yellow
|
||||
$gitStatus | ForEach-Object { Write-Host " $_" -ForegroundColor Yellow }
|
||||
exit 1
|
||||
}
|
||||
else {
|
||||
Write-Host " Uncommitted changes detected (allowed on dev branch)." -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Host " Working directory is clean." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# 3. Get version from csproj (source of truth)
|
||||
|
||||
Write-Host "Reading version(s) from csproj(s)..." -ForegroundColor Gray
|
||||
$projectVersions = @{}
|
||||
foreach ($projPath in $csprojPaths) {
|
||||
if (-not (Test-Path $projPath)) {
|
||||
Write-Error "Csproj not found at: $projPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[xml]$csproj = Get-Content $projPath
|
||||
$version = Get-CsprojPropertyValue -csproj $csproj -propertyName "Version"
|
||||
|
||||
if (-not $version) {
|
||||
Write-Error "Version not found in $projPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$projectVersions[$projPath] = $version
|
||||
Write-Host " $([System.IO.Path]::GetFileName($projPath)): $version" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Use the first project's version as the main version for tag/release
|
||||
$version = $projectVersions[$csprojPaths[0]]
|
||||
|
||||
# 4. Handle tag based on branch
|
||||
if ($isReleaseBranch) {
|
||||
# Main branch: tag is required and must match version
|
||||
Write-Host "Checking for tag on current commit..." -ForegroundColor Gray
|
||||
$tag = git describe --tags --exact-match HEAD 2>$null
|
||||
if ($LASTEXITCODE -ne 0 -or -not $tag) {
|
||||
Write-Error "No tag found on current commit. Create a tag: git tag v$version"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$tag = $tag.Trim()
|
||||
|
||||
if ($tag -notmatch '^v(\d+\.\d+\.\d+)$') {
|
||||
Write-Error "Tag '$tag' does not match expected format 'vX.Y.Z' (e.g., v$version)."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$tagVersion = $Matches[1]
|
||||
|
||||
if ($tagVersion -ne $version) {
|
||||
Write-Error "Tag version ($tagVersion) does not match csproj version ($version)."
|
||||
Write-Host " Either update the tag or the csproj version." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host " Tag found: $tag (matches csproj)" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
# Dev branch: no tag required, use version from csproj
|
||||
$tag = "v$version"
|
||||
Write-Host " Using version from csproj (no tag required on dev)." -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# 5. Verify CHANGELOG.md has matching version entry
|
||||
Write-Host "Verifying CHANGELOG.md..." -ForegroundColor Gray
|
||||
if (-not (Test-Path $changelogPath)) {
|
||||
Write-Error "CHANGELOG.md not found at: $changelogPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$changelog = Get-Content $changelogPath -Raw
|
||||
|
||||
if ($changelog -notmatch '##\s+v(\d+\.\d+\.\d+)') {
|
||||
Write-Error "No version entry found in CHANGELOG.md"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$changelogVersion = $Matches[1]
|
||||
|
||||
if ($changelogVersion -ne $version) {
|
||||
Write-Error "Csproj version ($version) does not match latest CHANGELOG.md version ($changelogVersion)."
|
||||
Write-Host " Update CHANGELOG.md or the csproj version." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host " CHANGELOG.md version matches: v$changelogVersion" -ForegroundColor Green
|
||||
|
||||
# 6. Check GitHub authentication (skip for local-only builds)
|
||||
if (-not $isDevBranch) {
|
||||
Write-Host "Checking GitHub authentication..." -ForegroundColor Gray
|
||||
if (-not $env:GH_TOKEN) {
|
||||
Write-Error "GH_TOKEN environment variable is not set. Set $tokenEnvVar and rerun."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$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." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "Skipping GitHub authentication (local-only mode)." -ForegroundColor Gray
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "All pre-flight checks passed!" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# ==============================================================================
|
||||
# RUN TESTS
|
||||
# ==============================================================================
|
||||
|
||||
Write-Host "Running tests..." -ForegroundColor Cyan
|
||||
|
||||
# Run tests using TestRunner module
|
||||
$testResult = Invoke-TestsWithCoverage -TestProjectPath $testProjectPath -Silent
|
||||
|
||||
if (-not $testResult.Success) {
|
||||
Write-Error "Tests failed. Release aborted."
|
||||
Write-Host " Error: $($testResult.Error)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host " All tests passed!" -ForegroundColor Green
|
||||
Write-Host " Line Coverage: $($testResult.LineRate)%" -ForegroundColor Gray
|
||||
Write-Host " Branch Coverage: $($testResult.BranchRate)%" -ForegroundColor Gray
|
||||
Write-Host " Method Coverage: $($testResult.MethodRate)%" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
# ==============================================================================
|
||||
# BUILD AND RELEASE
|
||||
# ==============================================================================
|
||||
|
||||
# 7. Prepare staging directory
|
||||
Write-Host "Preparing staging directory..." -ForegroundColor Cyan
|
||||
if (Test-Path $stagingDir) {
|
||||
Remove-Item $stagingDir -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $stagingDir | Out-Null
|
||||
|
||||
$binDir = Join-Path $stagingDir "bin"
|
||||
|
||||
# 8. Publish the project to staging/bin
|
||||
|
||||
Write-Host "Publishing projects to bin folder..." -ForegroundColor Cyan
|
||||
$publishSuccess = $true
|
||||
$publishedProjects = @()
|
||||
|
||||
foreach ($projPath in $csprojPaths) {
|
||||
$projName = [System.IO.Path]::GetFileNameWithoutExtension($projPath)
|
||||
$projBinDir = Join-Path $binDir $projName
|
||||
|
||||
dotnet publish $projPath -c Release -o $projBinDir
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "dotnet publish failed for $projName."
|
||||
$publishSuccess = $false
|
||||
}
|
||||
else {
|
||||
$exeBaseName = Resolve-ProjectExeName -projPath $projPath
|
||||
$publishedProjects += [PSCustomObject]@{
|
||||
ProjPath = $projPath
|
||||
ProjName = $projName
|
||||
BinDir = $projBinDir
|
||||
ExeBaseName = $exeBaseName
|
||||
}
|
||||
|
||||
Write-Host " Published $projName successfully to: $projBinDir" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $publishSuccess) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 9. Copy Scripts folder to staging
|
||||
Write-Host "Copying Scripts folder..." -ForegroundColor Cyan
|
||||
if (-not (Test-Path $scriptsPath)) {
|
||||
Write-Error "Scripts folder not found at: $scriptsPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$scriptsDestination = Join-Path $stagingDir "Scripts"
|
||||
Copy-Item -Path $scriptsPath -Destination $scriptsDestination -Recurse
|
||||
Write-Host " Scripts copied to: $scriptsDestination" -ForegroundColor Green
|
||||
|
||||
Write-Host "Updating ScheduleManager appsettings with UScheduler path..." -ForegroundColor Cyan
|
||||
|
||||
# 10. Update appsettings.json with scripts in disabled state
|
||||
# Dynamically locate ScheduleManager appsettings based on settings.projects.scheduleManagerCsprojEndsWith
|
||||
$scheduleManagerCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $scheduleManagerCsprojEndsWith
|
||||
if ($scheduleManagerCsprojPath) {
|
||||
$scheduleManagerProjName = [System.IO.Path]::GetFileNameWithoutExtension($scheduleManagerCsprojPath)
|
||||
$scheduleManagerBinDir = Join-Path $binDir $scheduleManagerProjName
|
||||
$scheduleManagerAppSettingsPath = Join-Path $scheduleManagerBinDir $scheduleManagerAppSettingsFile
|
||||
|
||||
if (Test-Path $scheduleManagerAppSettingsPath) {
|
||||
$smAppSettings = Get-Content $scheduleManagerAppSettingsPath -Raw | ConvertFrom-Json
|
||||
if ($smAppSettings.USchedulerSettings) {
|
||||
$smAppSettings.USchedulerSettings.ServiceBinPath = $scheduleManagerServiceBinPath
|
||||
$jsonOutput = $smAppSettings | ConvertTo-Json -Depth 10
|
||||
Set-Content -Path $scheduleManagerAppSettingsPath -Value $jsonOutput -Encoding UTF8
|
||||
Write-Host " Updated ServiceBinPath in ScheduleManager appsettings" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " Warning: USchedulerSettings section not found in ScheduleManager appsettings" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " Warning: $scheduleManagerAppSettingsFile not found in $scheduleManagerProjName bin folder" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " Warning: ScheduleManager csproj not found in csprojPaths array" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "Updating UScheduler appsettings with new LogDir bundled scripts paths..." -ForegroundColor Cyan
|
||||
|
||||
# Resolve UScheduler csproj by configured suffix (avoid hardcoded ScheduleManager exclusion)
|
||||
$uschedulerCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $uschedulerCsprojEndsWith
|
||||
if ($uschedulerCsprojPath) {
|
||||
$uschedulerProjName = [System.IO.Path]::GetFileNameWithoutExtension($uschedulerCsprojPath)
|
||||
$uschedulerBinDir = Join-Path $binDir $uschedulerProjName
|
||||
$appSettingsPath = Join-Path $uschedulerBinDir $uschedulerAppSettingsFile
|
||||
|
||||
if (Test-Path $appSettingsPath) {
|
||||
$appSettings = Get-Content $appSettingsPath -Raw | ConvertFrom-Json
|
||||
|
||||
# Update LogDir for release
|
||||
if ($appSettings.Configuration) {
|
||||
$appSettings.Configuration.LogDir = $uschedulerLogDir
|
||||
Write-Host " Updated LogDir in UScheduler appsettings" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " Warning: Configuration section not found in UScheduler appsettings" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Find all .ps1 files in Scripts folder (exclude utility scripts in subfolders named "Utilities")
|
||||
$psScripts = Get-ChildItem -Path $scriptsDestination -Filter "*.ps1" -Recurse |
|
||||
Where-Object { $_.Directory.Name -ne "Utilities" } |
|
||||
ForEach-Object {
|
||||
$relativePath = $_.FullName.Substring($scriptsDestination.Length + 1).Replace('/', '\')
|
||||
$scriptPath = "$scriptsRelativeToExe\$relativePath"
|
||||
[PSCustomObject]@{
|
||||
Path = $scriptPath
|
||||
IsSigned = $false
|
||||
Disabled = $true
|
||||
}
|
||||
}
|
||||
|
||||
# Add scripts to Powershell configuration
|
||||
if ($psScripts) {
|
||||
if (-not $appSettings.Configuration) {
|
||||
$appSettings | Add-Member -MemberType NoteProperty -Name "Configuration" -Value ([PSCustomObject]@{})
|
||||
}
|
||||
|
||||
$appSettings.Configuration.Powershell = @($psScripts)
|
||||
$jsonOutput = $appSettings | ConvertTo-Json -Depth 10
|
||||
Set-Content -Path $appSettingsPath -Value $jsonOutput -Encoding UTF8
|
||||
Write-Host " Added $($psScripts.Count) PowerShell script(s) to appsettings (disabled)" -ForegroundColor Green
|
||||
$psScripts | ForEach-Object { Write-Host " - $($_.Path)" -ForegroundColor Gray }
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " Warning: $uschedulerAppSettingsFile not found in $uschedulerProjName bin folder" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host " Warning: UScheduler csproj not found in csprojPaths array" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# 11. Create launcher batch file (if enabled)
|
||||
if ($settings.launcher -and $settings.launcher.enabled) {
|
||||
Write-Host "Creating launcher batch file..." -ForegroundColor Cyan
|
||||
|
||||
$launcherFileName = $settings.launcher.fileName
|
||||
$targetProject = $settings.launcher.targetProject
|
||||
|
||||
# Determine which project to launch
|
||||
$targetCsprojPath = $null
|
||||
$targetExeName = $null
|
||||
$targetProjName = $null
|
||||
|
||||
if ($targetProject -eq "scheduleManager") {
|
||||
$targetCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $scheduleManagerCsprojEndsWith
|
||||
}
|
||||
elseif ($targetProject -eq "uscheduler") {
|
||||
$targetCsprojPath = Find-CsprojByEndsWith -paths $csprojPaths -endsWith $uschedulerCsprojEndsWith
|
||||
}
|
||||
else {
|
||||
Write-Host " Warning: Unknown targetProject '$targetProject' in launcher settings" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if ($targetCsprojPath) {
|
||||
$targetProjName = [System.IO.Path]::GetFileNameWithoutExtension($targetCsprojPath)
|
||||
$targetExeName = Resolve-ProjectExeName -projPath $targetCsprojPath
|
||||
|
||||
$batPath = Join-Path $stagingDir $launcherFileName
|
||||
$exePath = "%~dp0bin\$targetProjName\$targetExeName.exe"
|
||||
|
||||
$batContent = @"
|
||||
@echo off
|
||||
start "" "$exePath"
|
||||
"@
|
||||
|
||||
Set-Content -Path $batPath -Value $batContent -Encoding ASCII
|
||||
Write-Host " Created launcher: $launcherFileName -> $exePath" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " Warning: Could not find target project for launcher" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "Skipping launcher batch file creation (disabled in settings)." -ForegroundColor Gray
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
# 12. Prepare release directory
|
||||
if (!(Test-Path $releaseDir)) {
|
||||
New-Item -ItemType Directory -Path $releaseDir | Out-Null
|
||||
}
|
||||
|
||||
# 13. Create zip file
|
||||
$zipName = $zipNamePattern -replace '\{version\}', $version
|
||||
$zipPath = Join-Path $releaseDir $zipName
|
||||
|
||||
if (Test-Path $zipPath) {
|
||||
Remove-Item $zipPath -Force
|
||||
}
|
||||
|
||||
Write-Host "Creating archive $zipName..." -ForegroundColor Cyan
|
||||
Compress-Archive -Path "$stagingDir\*" -DestinationPath $zipPath -Force
|
||||
|
||||
if (-not (Test-Path $zipPath)) {
|
||||
Write-Error "Failed to create archive $zipPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host " Archive created: $zipPath" -ForegroundColor Green
|
||||
|
||||
|
||||
# 14. Extract release notes from CHANGELOG.md
|
||||
Write-Host "Extracting release notes..." -ForegroundColor Cyan
|
||||
$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 " Release notes extracted." -ForegroundColor Green
|
||||
|
||||
# 15. Get repository info
|
||||
$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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
$releaseName = $releaseTitlePattern -replace '\{version\}', $version
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Release Summary:" -ForegroundColor Cyan
|
||||
Write-Host " Repository: $repo" -ForegroundColor White
|
||||
Write-Host " Tag: $tag" -ForegroundColor White
|
||||
Write-Host " Title: $releaseName" -ForegroundColor White
|
||||
Write-Host ""
|
||||
|
||||
# 16. Check if tag is pushed to remote (skip on dev branch)
|
||||
if (-not $isDevBranch) {
|
||||
Write-Host "Verifying tag is pushed to remote..." -ForegroundColor Cyan
|
||||
$remoteTag = git ls-remote --tags origin $tag 2>$null
|
||||
if (-not $remoteTag) {
|
||||
Write-Host " Tag $tag not found on remote. Pushing..." -ForegroundColor Yellow
|
||||
git push origin $tag
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to push tag $tag to remote."
|
||||
exit 1
|
||||
}
|
||||
Write-Host " Tag pushed successfully." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " Tag exists on remote." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# 17. Create or update GitHub release
|
||||
Write-Host "Creating GitHub release..." -ForegroundColor Cyan
|
||||
|
||||
# Check if release already exists
|
||||
gh release view $tag --repo $repo 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host " Release $tag already exists. Deleting..." -ForegroundColor Yellow
|
||||
gh release delete $tag --repo $repo --yes
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to delete existing release $tag."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Create new release using existing tag
|
||||
$ghArgs = @(
|
||||
"release", "create", $tag, $zipPath
|
||||
"--repo", $repo
|
||||
"--title", $releaseName
|
||||
"--notes", $releaseNotes
|
||||
)
|
||||
& gh @ghArgs
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to create GitHub release for tag $tag."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host " GitHub release created successfully." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "Skipping GitHub release (dev branch)." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# 18. Cleanup
|
||||
if (Test-Path $stagingDir) {
|
||||
Remove-Item $stagingDir -Recurse -Force
|
||||
Write-Host " Cleaned up staging directory." -ForegroundColor Gray
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==================================================" -ForegroundColor Green
|
||||
if ($isDevBranch) {
|
||||
Write-Host "DEV BUILD COMPLETE" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "RELEASE COMPLETE" -ForegroundColor Green
|
||||
}
|
||||
Write-Host "==================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
if (-not $isDevBranch) {
|
||||
Write-Host "Release URL: https://github.com/$repo/releases/tag/$tag" -ForegroundColor Cyan
|
||||
}
|
||||
Write-Host "Artifacts location: $releaseDir" -ForegroundColor Gray
|
||||
if ($isDevBranch) {
|
||||
Write-Host ""
|
||||
Write-Host "To publish to GitHub, merge to main, tag, and run the script again:" -ForegroundColor Yellow
|
||||
Write-Host " git checkout main" -ForegroundColor Yellow
|
||||
Write-Host " git merge dev" -ForegroundColor Yellow
|
||||
Write-Host " git tag v$version" -ForegroundColor Yellow
|
||||
Write-Host " .\Release-ToGitHub.ps1" -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ""
|
||||
57
utils/Release-ToGitHub/scriptsettings.json
Normal file
57
utils/Release-ToGitHub/scriptsettings.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema",
|
||||
"title": "Release to GitHub Script Settings",
|
||||
"description": "Configuration file for Release-ToGitHub.ps1 script (automated GitHub release creation)",
|
||||
"github": {
|
||||
"tokenEnvVar": "GITHUB_MAKS_IT_COM"
|
||||
},
|
||||
"branches": {
|
||||
"release": "main",
|
||||
"dev": "dev"
|
||||
},
|
||||
"paths": {
|
||||
"csprojPath": [
|
||||
"..\\..\\src\\MaksIT.UScheduler\\MaksIT.UScheduler.csproj",
|
||||
"..\\..\\src\\MaksIT.UScheduler.ScheduleManager\\MaksIT.UScheduler.ScheduleManager.csproj"
|
||||
],
|
||||
"stagingDir": "..\\..\\staging",
|
||||
"releaseDir": "..\\..\\release",
|
||||
"changelogPath": "..\\..\\CHANGELOG.md",
|
||||
"scriptsPath": "..\\..\\src\\Scripts",
|
||||
"testProject": "..\\..\\src\\MaksIT.UScheduler.Tests"
|
||||
},
|
||||
"release": {
|
||||
"zipNamePattern": "maksit.uscheduler-{version}.zip",
|
||||
"releaseTitlePattern": "Release {version}"
|
||||
},
|
||||
"launcher": {
|
||||
"enabled": true,
|
||||
"fileName": "Start-ScheduleManager.bat",
|
||||
"targetProject": "scheduleManager"
|
||||
},
|
||||
"projects": {
|
||||
"scheduleManagerCsprojEndsWith": "MaksIT.UScheduler.ScheduleManager.csproj",
|
||||
"uschedulerCsprojEndsWith": "MaksIT.UScheduler.csproj",
|
||||
"scheduleManagerAppSettingsFile": "appsettings.json",
|
||||
"uschedulerAppSettingsFile": "appsettings.json",
|
||||
"scheduleManagerServiceBinPath": "..\\MaksIT.UScheduler\\",
|
||||
"uschedulerLogDir": "..\\..\\Logs",
|
||||
"scriptsRelativeToExe": "..\\..\\Scripts"
|
||||
},
|
||||
"_comments": {
|
||||
"projects": {
|
||||
"scheduleManagerCsprojEndsWith": "Used to detect ScheduleManager csproj from csprojPath list",
|
||||
"uschedulerCsprojEndsWith": "Used to detect UScheduler csproj from csprojPath list",
|
||||
"scheduleManagerAppSettingsFile": "Config file name inside published output for ScheduleManager",
|
||||
"uschedulerAppSettingsFile": "Config file name inside published output for UScheduler",
|
||||
"scheduleManagerServiceBinPath": "Value written into USchedulerSettings.ServiceBinPath in ScheduleManager config",
|
||||
"uschedulerLogDir": "Value written into Configuration.LogDir in UScheduler config",
|
||||
"scriptsRelativeToExe": "Scripts base path relative to executable folder (used for appsettings script list)"
|
||||
},
|
||||
"shortcut": {
|
||||
"enabled": "If true, creates a .lnk in staging root",
|
||||
"projectRole": "Which project to point the shortcut to (ScheduleManager or UScheduler)",
|
||||
"fileName": "Shortcut file name in staging root"
|
||||
}
|
||||
}
|
||||
}
|
||||
158
utils/TestRunner.psm1
Normal file
158
utils/TestRunner.psm1
Normal file
@ -0,0 +1,158 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
PowerShell module for running tests with code coverage.
|
||||
|
||||
.DESCRIPTION
|
||||
Provides the Invoke-TestsWithCoverage function for running .NET tests
|
||||
with Coverlet code coverage collection and parsing results.
|
||||
|
||||
.NOTES
|
||||
Author: MaksIT
|
||||
Usage: Import-Module .\TestRunner.psm1
|
||||
#>
|
||||
|
||||
function Invoke-TestsWithCoverage {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs unit tests with code coverage and returns coverage metrics.
|
||||
|
||||
.PARAMETER TestProjectPath
|
||||
Path to the test project directory.
|
||||
|
||||
.PARAMETER Silent
|
||||
Suppress console output (for JSON consumption).
|
||||
|
||||
.PARAMETER KeepResults
|
||||
Keep the TestResults folder after execution.
|
||||
|
||||
.OUTPUTS
|
||||
PSCustomObject with properties:
|
||||
- Success: bool
|
||||
- Error: string (if failed)
|
||||
- LineRate: double
|
||||
- BranchRate: double
|
||||
- MethodRate: double
|
||||
- TotalMethods: int
|
||||
- CoveredMethods: int
|
||||
- CoverageFile: string
|
||||
|
||||
.EXAMPLE
|
||||
$result = Invoke-TestsWithCoverage -TestProjectPath ".\Tests"
|
||||
if ($result.Success) { Write-Host "Line coverage: $($result.LineRate)%" }
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TestProjectPath,
|
||||
|
||||
[switch]$Silent,
|
||||
|
||||
[switch]$KeepResults
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Resolve path
|
||||
$TestProjectDir = Resolve-Path $TestProjectPath -ErrorAction SilentlyContinue
|
||||
if (-not $TestProjectDir) {
|
||||
return [PSCustomObject]@{
|
||||
Success = $false
|
||||
Error = "Test project not found at: $TestProjectPath"
|
||||
}
|
||||
}
|
||||
|
||||
$ResultsDir = Join-Path $TestProjectDir "TestResults"
|
||||
|
||||
# Clean previous results
|
||||
if (Test-Path $ResultsDir) {
|
||||
Remove-Item -Recurse -Force $ResultsDir
|
||||
}
|
||||
|
||||
if (-not $Silent) {
|
||||
Write-Host "Running tests with code coverage..." -ForegroundColor Cyan
|
||||
Write-Host " Test Project: $TestProjectDir" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# Run tests with coverage collection
|
||||
Push-Location $TestProjectDir
|
||||
try {
|
||||
$dotnetArgs = @(
|
||||
"test"
|
||||
"--collect:XPlat Code Coverage"
|
||||
"--results-directory", $ResultsDir
|
||||
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
|
||||
)
|
||||
|
||||
if ($Silent) {
|
||||
$null = & dotnet @dotnetArgs 2>&1
|
||||
} else {
|
||||
& dotnet @dotnetArgs
|
||||
}
|
||||
|
||||
$testExitCode = $LASTEXITCODE
|
||||
if ($testExitCode -ne 0) {
|
||||
return [PSCustomObject]@{
|
||||
Success = $false
|
||||
Error = "Tests failed with exit code $testExitCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# Find the coverage file
|
||||
$CoverageFile = Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Select-Object -First 1
|
||||
|
||||
if (-not $CoverageFile) {
|
||||
return [PSCustomObject]@{
|
||||
Success = $false
|
||||
Error = "Coverage file not found"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Silent) {
|
||||
Write-Host "Coverage file found: $($CoverageFile.FullName)" -ForegroundColor Green
|
||||
Write-Host "Parsing coverage data..." -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# Parse coverage data from Cobertura XML
|
||||
[xml]$coverageXml = Get-Content $CoverageFile.FullName
|
||||
|
||||
$lineRate = [math]::Round([double]$coverageXml.coverage.'line-rate' * 100, 1)
|
||||
$branchRate = [math]::Round([double]$coverageXml.coverage.'branch-rate' * 100, 1)
|
||||
|
||||
# Calculate method coverage from packages
|
||||
$totalMethods = 0
|
||||
$coveredMethods = 0
|
||||
foreach ($package in $coverageXml.coverage.packages.package) {
|
||||
foreach ($class in $package.classes.class) {
|
||||
foreach ($method in $class.methods.method) {
|
||||
$totalMethods++
|
||||
if ([double]$method.'line-rate' -gt 0) {
|
||||
$coveredMethods++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$methodRate = if ($totalMethods -gt 0) { [math]::Round(($coveredMethods / $totalMethods) * 100, 1) } else { 0 }
|
||||
|
||||
# Cleanup unless KeepResults is specified
|
||||
if (-not $KeepResults) {
|
||||
if (Test-Path $ResultsDir) {
|
||||
Remove-Item -Recurse -Force $ResultsDir
|
||||
}
|
||||
}
|
||||
|
||||
# Return results
|
||||
return [PSCustomObject]@{
|
||||
Success = $true
|
||||
LineRate = $lineRate
|
||||
BranchRate = $branchRate
|
||||
MethodRate = $methodRate
|
||||
TotalMethods = $totalMethods
|
||||
CoveredMethods = $coveredMethods
|
||||
CoverageFile = $CoverageFile.FullName
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-TestsWithCoverage
|
||||
Loading…
Reference in New Issue
Block a user