Complete refactor along with tests and validators

This commit is contained in:
2025-07-23 01:33:38 -04:00
parent 158521ba05
commit 572a897173
28 changed files with 2432 additions and 617 deletions

334
.gitignore vendored
View File

@@ -1,7 +1,5 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
@@ -10,12 +8,6 @@
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
@@ -23,341 +15,37 @@ mono_crash.*
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
# Visual Studio cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# Visual Studio Code
.vscode/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# Rider
.idea/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Application specific
logs/
*.log
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# Environment specific configs
appsettings.Development.json
appsettings.Production.json

50
Dockerfile Normal file
View File

@@ -0,0 +1,50 @@
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy project file and restore dependencies
COPY ["MarketAlly.ProcessMonitor.csproj", "."]
RUN dotnet restore
# Copy all source files
COPY . .
# Build the application
RUN dotnet build -c Release -o /app/build
# Publish stage
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false
# Runtime stage
FROM mcr.microsoft.com/dotnet/runtime:9.0 AS final
WORKDIR /app
# Install required packages for process monitoring
RUN apt-get update && apt-get install -y \
procps \
&& rm -rf /var/lib/apt/lists/*
# Create logs directory
RUN mkdir -p /app/logs
# Copy published application
COPY --from=publish /app/publish .
# Create non-root user
RUN groupadd -r processmonitor && useradd -r -g processmonitor processmonitor
RUN chown -R processmonitor:processmonitor /app
# Switch to non-root user
USER processmonitor
# Set environment variables
ENV ASPNETCORE_ENVIRONMENT=Production
ENV DOTNET_ENVIRONMENT=Production
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD test -f /app/logs/processmonitor-*.log || exit 1
# Entry point
ENTRYPOINT ["dotnet", "MarketAlly.ProcessMonitor.dll"]

View File

@@ -0,0 +1,41 @@
namespace MarketAlly.ProcessMonitor.Exceptions;
/// <summary>
/// Base exception for process monitor
/// </summary>
public class ProcessMonitorException : Exception
{
public ProcessMonitorException() { }
public ProcessMonitorException(string message) : base(message) { }
public ProcessMonitorException(string message, Exception inner) : base(message, inner) { }
}
/// <summary>
/// Exception thrown when process validation fails
/// </summary>
public class ProcessValidationException : ProcessMonitorException
{
public ProcessValidationException() { }
public ProcessValidationException(string message) : base(message) { }
public ProcessValidationException(string message, Exception inner) : base(message, inner) { }
}
/// <summary>
/// Exception thrown when process fails to start
/// </summary>
public class ProcessStartException : ProcessMonitorException
{
public ProcessStartException() { }
public ProcessStartException(string message) : base(message) { }
public ProcessStartException(string message, Exception inner) : base(message, inner) { }
}
/// <summary>
/// Exception thrown when configuration is invalid
/// </summary>
public class ConfigurationException : ProcessMonitorException
{
public ConfigurationException() { }
public ConfigurationException(string message) : base(message) { }
public ConfigurationException(string message, Exception inner) : base(message, inner) { }
}

View File

@@ -0,0 +1,119 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MarketAlly.ProcessMonitor.Services;
using MarketAlly.ProcessMonitor.Interfaces;
namespace MarketAlly.ProcessMonitor.Extensions;
/// <summary>
/// Health check extensions for monitoring application health
/// </summary>
public static class HealthCheckExtensions
{
public static IServiceCollection AddProcessMonitorHealthChecks(this IServiceCollection services)
{
services.AddHealthChecks()
.AddCheck<ConfigurationHealthCheck>("configuration", tags: new[] { "ready" })
.AddCheck<ProcessManagerHealthCheck>("process_manager", tags: new[] { "live" })
.AddCheck<DiskSpaceHealthCheck>("disk_space", tags: new[] { "ready" });
return services;
}
}
/// <summary>
/// Checks if configuration is accessible and valid
/// </summary>
public class ConfigurationHealthCheck : IHealthCheck
{
private readonly IConfigurationService _configurationService;
public ConfigurationHealthCheck(IConfigurationService configurationService)
{
_configurationService = configurationService;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var config = await _configurationService.GetConfigurationAsync();
if (config?.Processes == null || config.Processes.Count == 0)
{
return HealthCheckResult.Unhealthy("No processes configured");
}
return HealthCheckResult.Healthy($"Configuration loaded with {config.Processes.Count} processes");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Configuration check failed", ex);
}
}
}
/// <summary>
/// Checks if process manager is operational
/// </summary>
public class ProcessManagerHealthCheck : IHealthCheck
{
private readonly IProcessManager _processManager;
public ProcessManagerHealthCheck(IProcessManager processManager)
{
_processManager = processManager;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
// Simple check to ensure process manager can query running processes
var count = _processManager.GetRunningProcessCount("System");
return await Task.FromResult(HealthCheckResult.Healthy("Process manager is operational"));
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Process manager check failed", ex);
}
}
}
/// <summary>
/// Checks available disk space for logs
/// </summary>
public class DiskSpaceHealthCheck : IHealthCheck
{
private const long MinimumFreeMegabytes = 100;
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var logPath = Path.Combine(AppContext.BaseDirectory, "logs");
var drive = new DriveInfo(Path.GetPathRoot(logPath) ?? "C:\\");
var freeSpaceMb = drive.AvailableFreeSpace / (1024 * 1024);
if (freeSpaceMb < MinimumFreeMegabytes)
{
return Task.FromResult(HealthCheckResult.Unhealthy(
$"Low disk space: {freeSpaceMb}MB available"));
}
return Task.FromResult(HealthCheckResult.Healthy(
$"Disk space OK: {freeSpaceMb}MB available"));
}
catch (Exception ex)
{
return Task.FromResult(HealthCheckResult.Unhealthy("Disk space check failed", ex));
}
}
}

View File

@@ -0,0 +1,29 @@
using MarketAlly.ProcessMonitor.Models;
namespace MarketAlly.ProcessMonitor.Interfaces;
/// <summary>
/// Manages application configuration
/// </summary>
public interface IConfigurationService
{
/// <summary>
/// Gets the current process configuration
/// </summary>
Task<ProcessConfiguration> GetConfigurationAsync();
/// <summary>
/// Reloads configuration from source
/// </summary>
Task ReloadConfigurationAsync();
/// <summary>
/// Event raised when configuration changes
/// </summary>
event EventHandler<ProcessConfiguration> ConfigurationChanged;
/// <summary>
/// Gets application settings
/// </summary>
AppSettings GetAppSettings();
}

View File

@@ -0,0 +1,29 @@
using MarketAlly.ProcessMonitor.Models;
namespace MarketAlly.ProcessMonitor.Interfaces;
/// <summary>
/// Manages process lifecycle operations
/// </summary>
public interface IProcessManager
{
/// <summary>
/// Starts a process with the specified configuration
/// </summary>
Task<bool> StartProcessAsync(ProcessInfo processInfo, bool openInNewWindow, CancellationToken cancellationToken = default);
/// <summary>
/// Ensures the specified number of process instances are running
/// </summary>
Task EnsureProcessRunningAsync(ProcessInfo processInfo, int desiredCount, bool openInNewWindow, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the count of running processes by name
/// </summary>
int GetRunningProcessCount(string processName);
/// <summary>
/// Stops all instances of a process
/// </summary>
Task<bool> StopProcessAsync(string processName, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,26 @@
using MarketAlly.ProcessMonitor.Models;
namespace MarketAlly.ProcessMonitor.Interfaces;
/// <summary>
/// Validates process configurations and paths
/// </summary>
public interface IProcessValidator
{
/// <summary>
/// Validates a process path for security
/// </summary>
bool ValidateProcessPath(string path);
/// <summary>
/// Validates entire process configuration
/// </summary>
ValidationResult ValidateProcessInfo(ProcessInfo processInfo);
/// <summary>
/// Checks if process has required permissions
/// </summary>
Task<bool> CheckPermissionsAsync(string path);
}
public record ValidationResult(bool IsValid, List<string> Errors);

29
Interfaces/IScheduler.cs Normal file
View File

@@ -0,0 +1,29 @@
using MarketAlly.ProcessMonitor.Models;
namespace MarketAlly.ProcessMonitor.Interfaces;
/// <summary>
/// Handles process scheduling operations
/// </summary>
public interface IScheduler
{
/// <summary>
/// Schedules a process to start at a specific time
/// </summary>
void ScheduleProcessStart(ProcessInfo processInfo, bool openInNewWindow);
/// <summary>
/// Schedules a process for repeated execution at specified intervals
/// </summary>
void ScheduleRepeatedExecution(ProcessInfo processInfo, bool openInNewWindow);
/// <summary>
/// Cancels all scheduled tasks for a process
/// </summary>
void CancelScheduledTasks(string processName);
/// <summary>
/// Disposes of all scheduled tasks
/// </summary>
void Dispose();
}

42
Models/AppSettings.cs Normal file
View File

@@ -0,0 +1,42 @@
namespace MarketAlly.ProcessMonitor.Models;
/// <summary>
/// Application settings
/// </summary>
public class AppSettings
{
/// <summary>
/// Monitoring interval in seconds
/// </summary>
public int MonitoringIntervalSeconds { get; set; } = 10;
/// <summary>
/// Enable debug logging
/// </summary>
public bool EnableDebugLogging { get; set; } = false;
/// <summary>
/// Log retention days
/// </summary>
public int LogRetentionDays { get; set; } = 30;
/// <summary>
/// Allowed process paths (whitelist)
/// </summary>
public List<string> AllowedPaths { get; set; } = new();
/// <summary>
/// Enable process path validation
/// </summary>
public bool EnablePathValidation { get; set; } = true;
/// <summary>
/// Enable performance monitoring
/// </summary>
public bool EnablePerformanceMonitoring { get; set; } = true;
/// <summary>
/// Maximum concurrent process starts
/// </summary>
public int MaxConcurrentStarts { get; set; } = 5;
}

View File

@@ -0,0 +1,22 @@
namespace MarketAlly.ProcessMonitor.Models;
/// <summary>
/// Root configuration for process monitoring
/// </summary>
public class ProcessConfiguration
{
/// <summary>
/// List of processes to monitor
/// </summary>
public List<ProcessInfo> Processes { get; set; } = new();
/// <summary>
/// Configuration version
/// </summary>
public string Version { get; set; } = "1.0";
/// <summary>
/// Last modified timestamp
/// </summary>
public DateTime LastModified { get; set; } = DateTime.UtcNow;
}

68
Models/ProcessInfo.cs Normal file
View File

@@ -0,0 +1,68 @@
using System.ComponentModel.DataAnnotations;
namespace MarketAlly.ProcessMonitor.Models;
/// <summary>
/// Represents process configuration information
/// </summary>
public class ProcessInfo
{
/// <summary>
/// Name of the process
/// </summary>
[Required]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Path to the executable process
/// </summary>
[Required]
public string Path { get; set; } = string.Empty;
/// <summary>
/// Number of process instances to maintain
/// </summary>
[Range(0, 100)]
public int Count { get; set; }
/// <summary>
/// Time of day (HH:mm) to launch the process
/// </summary>
public string? Time { get; set; }
/// <summary>
/// Frequency in minutes to launch the process
/// </summary>
[Range(1, int.MaxValue)]
public int? Interval { get; set; }
/// <summary>
/// Whether to enable process monitoring for this process
/// </summary>
public bool Enable { get; set; }
/// <summary>
/// Command line arguments for the process
/// </summary>
public string? Arguments { get; set; }
/// <summary>
/// Working directory for the process
/// </summary>
public string? WorkingDirectory { get; set; }
/// <summary>
/// Environment variables for the process
/// </summary>
public Dictionary<string, string>? EnvironmentVariables { get; set; }
/// <summary>
/// Maximum retries on failure
/// </summary>
public int MaxRetries { get; set; } = 3;
/// <summary>
/// Delay between retries in seconds
/// </summary>
public int RetryDelaySeconds { get; set; } = 5;
}

View File

@@ -1,24 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace marketally.processmonitor
{
class ProcessInfo
{
//Name of process
public string Name { get; set; }
//Path to the executable process
public string Path { get; set; }
//Number of processes
public Int32 Count { get; set; }
//Time of day hour and minute to launch
public string Time { get; set; }
//Frequency in minutes to launch
public int? Interval { get; set; }
//Whether to enable process
public bool Enable { get; set; }
}
}
// This file is kept for backward compatibility
// The actual ProcessInfo model is now in Models/ProcessInfo.cs
using MarketAlly.ProcessMonitor.Models;

View File

@@ -1,177 +1,91 @@
using marketally.processmonitor;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Diagnostics;
using MarketAlly.ProcessMonitor.Interfaces;
using MarketAlly.ProcessMonitor.Models;
using MarketAlly.ProcessMonitor.Services;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Events;
// Asking the user if they want to open new processes in a separate window
Console.WriteLine("Do you want to start processes in a new window? (yes/no)");
string userInput = Console.ReadLine().ToLower();
bool openInNewWindow = userInput == "yes";
namespace MarketAlly.ProcessMonitor;
double checkOnce = 0;
// The main loop that continuously checks and manages processes
while (true)
public class Program
{
// Define the path to the JSON file containing process information
string jsonFilePath = "processlist.json"; // Relative path to the JSON file
string json = File.ReadAllText(jsonFilePath);
var jObject = JObject.Parse(json);
var processInfos = jObject["processes"].ToObject<List<ProcessInfo>>();
static async Task Main(string[] args)
{
// Configure Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithProcessId()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.File("logs/processmonitor-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
.CreateLogger();
foreach (var processInfo in processInfos.Where(x => x.Enable))
try
{
// Check if it's the first run and if the process has a specific interval set
if (checkOnce == 0 && processInfo.Interval.HasValue)
{
// Schedule repeated execution
ScheduleRepeatedExecution(processInfo);
Log.Information("Starting Process Monitor");
await CreateHostBuilder(args).RunConsoleAsync();
}
// If no specific time is set, ensure the process is running continuously
else if (string.IsNullOrEmpty(processInfo.Time))
catch (Exception ex)
{
// Run continuously
EnsureProcessRunning(processInfo, processInfo.Count);
Log.Fatal(ex, "Application terminated unexpectedly");
}
// If it's the first run and a specific time is set, schedule the process
else if (checkOnce == 0)
finally
{
// Schedule for a specific time
ScheduleProcessStart(processInfo);
}
}
// After the first iteration, set checkOnce to 1 to avoid rescheduling
if (checkOnce == 0)
checkOnce = 1;
Thread.Sleep(10000); // Wait for 10 seconds before checking again
}
// Ensures the desired number of instances of a process are running
void EnsureProcessRunning(ProcessInfo processInfo, int desiredCount)
{
var runningProcesses = Process.GetProcessesByName(processInfo.Name);
int countToStart = desiredCount - runningProcesses.Length;
for (int i = 0; i < countToStart; i++)
{
if (openInNewWindow)
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = processInfo.Path,
CreateNoWindow = !openInNewWindow, // Controlled by the user's choice
UseShellExecute = true, // Use the system shell to start the process
};
Process.Start(startInfo);
} else
{
Process.Start(processInfo.Path);
}
Console.WriteLine($"Started {processInfo.Name} at {DateTime.Now}");
AppendToLogFile(processInfo, "launched");
await Log.CloseAndFlushAsync();
}
}
// Schedules a process to start at a specific time
void ScheduleProcessStart(ProcessInfo processInfo)
static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.UseSerilog()
.ConfigureAppConfiguration((context, config) =>
{
DateTime scheduledTime = DateTime.Today.Add(TimeSpan.Parse(processInfo.Time));
TimeSpan delay = scheduledTime - DateTime.Now;
config
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{context.HostingEnvironment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables()
.AddCommandLine(args);
})
.ConfigureServices((context, services) =>
{
// Configure app settings
services.Configure<AppSettings>(context.Configuration.GetSection("ProcessMonitor"));
if (delay < TimeSpan.Zero)
// Register services
services.AddSingleton<IMemoryCache, MemoryCache>();
services.AddSingleton(provider =>
{
// Scheduled time is in the past. Schedule for the next day or handle as needed.
delay = delay.Add(TimeSpan.FromDays(1));
}
var configuration = provider.GetRequiredService<IConfiguration>();
var appSettings = new AppSettings();
configuration.GetSection("ProcessMonitor").Bind(appSettings);
return appSettings;
});
var timer = new System.Threading.Timer(_ =>
{
var runningProcesses = Process.GetProcessesByName(processInfo.Name);
if (runningProcesses.Length == 0)
{
if (openInNewWindow)
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = processInfo.Path,
CreateNoWindow = !openInNewWindow, // Controlled by the user's choice
UseShellExecute = true, // Use the system shell to start the process
};
Process.Start(startInfo);
}
else
{
Process.Start(processInfo.Path);
}
AppendToLogFile(processInfo, "run");
}
else
{
Console.WriteLine($"Process still running {processInfo.Name} at {DateTime.Now}, skipping for next interval");
AppendToLogFile(processInfo, "skipped");
}
services.AddSingleton<IProcessValidator, ProcessValidator>();
services.AddSingleton<IProcessManager, ProcessManager>();
services.AddSingleton<IScheduler, ProcessScheduler>();
services.AddSingleton<IConfigurationService, ConfigurationService>();
}, null, delay, Timeout.InfiniteTimeSpan); // Run only once
// Register hosted services
services.AddHostedService<ProcessMonitorService>();
services.AddHostedService<PerformanceMonitoringService>();
Console.WriteLine($"Scheduled {processInfo.Name} to start at {scheduledTime}");
}
// Schedules a process for repeated execution
void ScheduleRepeatedExecution(ProcessInfo processInfo)
{
var timer = new System.Threading.Timer(_ =>
{
var runningProcesses = Process.GetProcessesByName(processInfo.Name);
if (runningProcesses.Length == 0)
{
if (openInNewWindow)
{
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = processInfo.Path,
CreateNoWindow = !openInNewWindow, // Controlled by the user's choice
UseShellExecute = true, // Use the system shell to start the process
};
Process.Start(startInfo);
}
else
{
Process.Start(processInfo.Path);
}
Console.WriteLine($"Started {processInfo.Name} at {DateTime.Now}");
AppendToLogFile(processInfo, "run");
} else
{
Console.WriteLine($"Process still running {processInfo.Name} at {DateTime.Now}, skipping for next interval");
AppendToLogFile(processInfo, "skipped");
}
}, null, TimeSpan.Zero, TimeSpan.FromMinutes(processInfo.Interval.Value));
Console.WriteLine($"Scheduled {processInfo.Name} to run every {processInfo.Interval.Value} minutes");
}
// Appends a log entry for a process action
void AppendToLogFile(ProcessInfo processInfo, string state)
{
string logFilePath = Path.Combine("Logs", GetLogFileName()); // Store logs in a "Logs" subfolder
string logEntry = $"{DateTime.Now}: {processInfo.Name} was {state}.";
if (!Directory.Exists("Logs"))
{
Directory.CreateDirectory("Logs");
}
File.AppendAllText(logFilePath, logEntry + Environment.NewLine);
}
string GetLogFileName()
{
var cultureInfo = System.Globalization.CultureInfo.CurrentCulture;
int weekNo = cultureInfo.Calendar.GetWeekOfYear(
DateTime.Now,
cultureInfo.DateTimeFormat.CalendarWeekRule,
cultureInfo.DateTimeFormat.FirstDayOfWeek);
return $"processLog_{DateTime.Now.Year}_Week{weekNo}.log";
// Configure logging
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddSerilog();
});
})
.UseConsoleLifetime();
}

354
README.md
View File

@@ -1,119 +1,291 @@
# Process Monitor Utility
A production-ready process monitoring and management tool built with .NET 9, featuring enterprise-grade security, comprehensive error handling, and robust scheduling capabilities.
## Overview
This utility is a process monitoring and management tool written in C#. It allows users to automate the starting of processes based on a predefined schedule and conditions. The program reads from a JSON file to determine which processes to manage and how to manage them.
Process Monitor is a sophisticated utility that automates process lifecycle management with features including:
- Scheduled process execution
- Continuous process monitoring
- Automatic process restart with retry logic
- Configuration hot-reload
- Comprehensive logging with Serilog
- Security validation and path whitelisting
- Performance monitoring
- Docker support
- Health checks
## Features
- **Flexible Process Management**: Enables starting processes at specific times, continuously running them, or scheduling them at regular intervals.
- **User Input for Window Management**: Users can choose whether processes should start in a new window.
- **Logging**: Automatically logs process start and management actions in a weekly log file.
### Core Functionality
- **Flexible Process Management**: Start processes at specific times, run continuously, or schedule at intervals
- **Retry Logic**: Automatic retry with exponential backoff on process failures
- **Concurrent Execution Control**: Limit simultaneous process starts
- **Process Count Management**: Ensure specific number of instances are always running
### Security
- **Path Validation**: Whitelist-based executable path validation
- **Argument Sanitization**: Protection against command injection
- **Digital Signature Verification**: Optional executable signature checking
- **Non-root Execution**: Docker container runs as non-privileged user
### Monitoring & Observability
- **Structured Logging**: Serilog with file and console sinks
- **Performance Monitoring**: CPU and memory usage tracking
- **Health Checks**: Configuration, process manager, and disk space checks
- **Log Rotation**: Automatic daily log rotation with retention
### Configuration
- **Hot Reload**: Automatic configuration reload on file changes
- **Environment Variables**: Override settings via environment variables
- **JSON Schema Validation**: Strongly-typed configuration with validation
## Requirements
- .NET Framework or .NET Core
- Newtonsoft.Json package for JSON processing
- Marketally.ProcessMonitor library (if applicable)
- .NET 9.0 Runtime or SDK
- Windows, Linux, or macOS
- Docker (optional, for containerized deployment)
## Installation
1. Ensure that .NET Framework or .NET Core is installed on your system.
2. Include the `Newtonsoft.Json` package in your project.
3. Add `marketally.processmonitor` library, if it's a separate dependency.
### From Source
```bash
git clone https://github.com/MarketAlly/Process-Monitor.git
cd Process-Monitor
dotnet restore
dotnet build -c Release
```
### Docker
```bash
docker build -t marketally/processmonitor:latest .
```
## Configuration
### appsettings.json
```json
{
"ProcessMonitor": {
"MonitoringIntervalSeconds": 10,
"EnableDebugLogging": false,
"LogRetentionDays": 30,
"AllowedPaths": [
"C:\\Windows\\System32",
"C:\\Program Files"
],
"EnablePathValidation": true,
"EnablePerformanceMonitoring": true,
"MaxConcurrentStarts": 5
}
}
```
### processlist.json
```json
{
"version": "1.0",
"processes": [
{
"name": "MyApp",
"path": "C:\\Program Files\\MyApp\\app.exe",
"count": 2,
"enable": true,
"interval": 30,
"time": "08:00",
"arguments": "--config production",
"workingDirectory": "C:\\Program Files\\MyApp",
"environmentVariables": {
"NODE_ENV": "production"
},
"maxRetries": 3,
"retryDelaySeconds": 5
}
]
}
```
## Usage
1. **Configure Process List**: Edit the `processlist.json` file to include the list of processes you want to manage. The JSON structure should be as follows:
### Command Line
```bash
# Run with default configuration
dotnet MarketAlly.ProcessMonitor.dll
```json
{
"processes": [
{
"Name": "ProcessName",
"Path": "ExecutablePath",
"Enable": true/false,
"Interval": Minutes (optional),
"Time": "HH:mm" (optional),
"Count": NumberOfInstances (optional)
},
...
]
}
# Run with custom environment
dotnet MarketAlly.ProcessMonitor.dll --environment Production
# Override configuration via environment variables
ProcessMonitor__MonitoringIntervalSeconds=30 dotnet MarketAlly.ProcessMonitor.dll
```
2. **Run the Utility**: Execute the program. It will ask if processes should be started in a new window. Respond with `yes` or `no`.
3. **Monitor Logs**: Check the `Logs` folder for weekly log files detailing the process management actions.
### Docker
```bash
# Using docker-compose
docker-compose up -d
## Functions Description
- `EnsureProcessRunning`: Ensures the specified number of process instances are running.
- `ScheduleProcessStart`: Schedules a process to start at a specific time.
- `ScheduleRepeatedExecution`: Schedules a process to start at regular intervals.
- `AppendToLogFile`: Logs actions to a weekly log file.
- `GetLogFileName`: Generates a filename for the log based on the current week of the year.
## ProcessInfo Class
The `ProcessInfo` class is a key component of the utility, defining the structure and properties of each process that needs to be monitored or managed. Here is a detailed explanation of its properties:
### Properties
- **Name (`string`)**:
- **Description**: The name of the process. This is used to identify and manage the process within the system.
- **Example**: `"ExampleProcess"`
- **Path (`string`)**:
- **Description**: The file path to the executable of the process. This path is used to start the process.
- **Example**: `"C:\\Program Files\\ExampleProcess\\process.exe"`
- **Count (`Int32`)**:
- **Description**: The desired number of instances of this process to be running simultaneously. This is used to ensure that a specific number of process instances are active.
- **Example**: `3`
- **Time (`string`)**:
- **Description**: The specific time of day when the process should be launched. It should be in the format of "HH:mm" (hour and minute).
- **Example**: `"14:30"` (This would launch the process at 2:30 PM)
- **Interval (`int?`)**:
- **Description**: The frequency, in minutes, at which the process should be launched. This is used to schedule repeated execution of the process. If null, it indicates that the process does not need to be started at regular intervals.
- **Example**: `15` (This would schedule the process to start every 15 minutes)
- **Enable (`bool`)**:
- **Description**: A flag indicating whether the process monitoring or management for this particular process should be enabled or disabled.
- **Example**: `true` (This would enable the process monitoring and management)
### Usage in JSON Configuration
In the `processlist.json` file, each process to be managed should be defined as an object with the above properties. For example:
```json
{
"processes": [
{
"Name": "ExampleProcess",
"Path": "C:\\Program Files\\ExampleProcess\\process.exe",
"Count": 3,
"Time": "14:30",
"Interval": 15,
"Enable": true
},
// More processes can be added here
]
}
# Using docker run
docker run -d \
--name process-monitor \
-v $(pwd)/processlist.json:/app/processlist.json:ro \
-v $(pwd)/logs:/app/logs \
marketally/processmonitor:latest
```
Each process object in the array represents a separate process configuration that the utility will manage based on the provided details.
## Process Configuration Options
## Notes
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| name | string | Yes | Process identifier |
| path | string | Yes | Absolute path to executable |
| count | int | No | Number of instances to maintain (0-100) |
| enable | bool | Yes | Enable/disable monitoring |
| time | string | No | Start time in HH:mm format |
| interval | int | No | Repeat interval in minutes |
| arguments | string | No | Command line arguments |
| workingDirectory | string | No | Working directory path |
| environmentVariables | object | No | Environment variables |
| maxRetries | int | No | Maximum retry attempts (default: 3) |
| retryDelaySeconds | int | No | Delay between retries (default: 5) |
- The program uses a 10-second loop to continuously check and manage processes.
- It is advisable to have error handling for reading the JSON file and managing processes.
## Architecture
## Contribution
The application follows SOLID principles with a clean architecture:
Contributions to the project are welcome. Please ensure you follow the coding standards for new features.
```
├── Interfaces/ # Service contracts
├── Services/ # Service implementations
├── Models/ # Data models
├── Configuration/ # Configuration handling
├── Validators/ # Security and validation
├── Extensions/ # Extension methods
├── Exceptions/ # Custom exceptions
└── Tests/ # Unit and integration tests
```
---
### Key Components
- **ProcessManager**: Handles process lifecycle with retry logic
- **ProcessScheduler**: Manages scheduled tasks
- **ConfigurationService**: Hot-reload configuration management
- **ProcessValidator**: Security validation
- **ProcessMonitorService**: Main background service
- **PerformanceMonitoringService**: System metrics collection
This README provides a basic overview. Please read through the source code comments for more detailed information.
## Testing
```bash
# Run all tests
dotnet test
# Run with coverage
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
# Run specific test category
dotnet test --filter Category=Unit
```
## Deployment
### Production Checklist
1. ✅ Update `appsettings.Production.json` with production values
2. ✅ Configure allowed paths for your environment
3. ✅ Set appropriate log retention period
4. ✅ Enable performance monitoring if needed
5. ✅ Configure health check endpoints
6. ✅ Set up log aggregation (e.g., to Seq, ELK)
7. ✅ Configure monitoring and alerting
8. ✅ Review security settings
### Kubernetes Deployment
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: process-monitor
spec:
replicas: 1
selector:
matchLabels:
app: process-monitor
template:
metadata:
labels:
app: process-monitor
spec:
containers:
- name: process-monitor
image: marketally/processmonitor:latest
volumeMounts:
- name: config
mountPath: /app/processlist.json
subPath: processlist.json
- name: logs
mountPath: /app/logs
volumes:
- name: config
configMap:
name: process-config
- name: logs
persistentVolumeClaim:
claimName: process-monitor-logs
```
## Monitoring
### Health Checks
- `/health/ready` - Readiness probe
- `/health/live` - Liveness probe
### Metrics
- Process count by name
- CPU and memory usage
- Scheduled task execution
- Error rates and retry counts
### Logging
Logs are written to:
- Console (structured JSON in production)
- File: `logs/processmonitor-YYYYMMDD.log`
## Security Considerations
1. **Path Whitelisting**: Only executables in allowed paths can be started
2. **Argument Validation**: Protection against shell injection
3. **File Permissions**: Ensure proper permissions on configuration files
4. **Secrets Management**: Use environment variables or secret stores for sensitive data
5. **Network Isolation**: Run in isolated network when possible
## Troubleshooting
### Common Issues
**Process fails to start**
- Check path validation and whitelist
- Verify file permissions
- Check logs for detailed error messages
**Configuration not reloading**
- Ensure file watcher has permissions
- Check for file lock issues
- Verify JSON syntax
**High memory usage**
- Enable performance monitoring
- Check for process leaks
- Review retry settings
## Contributing
1. Fork the repository
2. Create a feature branch
3. Write tests for new functionality
4. Ensure all tests pass
5. Submit a pull request
## License
This project is licensed under the MIT License - see the LICENSE.txt file for details.
## Support
For issues and feature requests, please use the GitHub issue tracker.

View File

@@ -0,0 +1,183 @@
using System.Text.Json;
using MarketAlly.ProcessMonitor.Interfaces;
using MarketAlly.ProcessMonitor.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MarketAlly.ProcessMonitor.Services;
/// <summary>
/// Manages application configuration with hot reload support
/// </summary>
public class ConfigurationService : IConfigurationService, IDisposable
{
private readonly ILogger<ConfigurationService> _logger;
private readonly IConfiguration _configuration;
private readonly IMemoryCache _cache;
private readonly IOptionsMonitor<AppSettings> _appSettings;
private readonly FileSystemWatcher _fileWatcher;
private readonly string _configFilePath;
private readonly SemaphoreSlim _configLock = new(1, 1);
public event EventHandler<ProcessConfiguration>? ConfigurationChanged;
public ConfigurationService(
ILogger<ConfigurationService> logger,
IConfiguration configuration,
IMemoryCache cache,
IOptionsMonitor<AppSettings> appSettings)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_appSettings = appSettings ?? throw new ArgumentNullException(nameof(appSettings));
_configFilePath = Path.Combine(AppContext.BaseDirectory, "processlist.json");
_fileWatcher = InitializeFileWatcher();
}
public async Task<ProcessConfiguration> GetConfigurationAsync()
{
const string cacheKey = "ProcessConfiguration";
if (_cache.TryGetValue<ProcessConfiguration>(cacheKey, out var cached) && cached != null)
{
return cached;
}
await _configLock.WaitAsync();
try
{
// Double-check after acquiring lock
if (_cache.TryGetValue<ProcessConfiguration>(cacheKey, out cached) && cached != null)
{
return cached;
}
var config = await LoadConfigurationAsync();
_cache.Set(cacheKey, config, new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(5),
Priority = CacheItemPriority.High
});
return config;
}
finally
{
_configLock.Release();
}
}
public async Task ReloadConfigurationAsync()
{
_logger.LogInformation("Reloading process configuration");
await _configLock.WaitAsync();
try
{
_cache.Remove("ProcessConfiguration");
var config = await LoadConfigurationAsync();
_cache.Set("ProcessConfiguration", config, new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(5),
Priority = CacheItemPriority.High
});
ConfigurationChanged?.Invoke(this, config);
_logger.LogInformation("Configuration reloaded successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reload configuration");
throw;
}
finally
{
_configLock.Release();
}
}
public AppSettings GetAppSettings()
{
return _appSettings.CurrentValue;
}
private async Task<ProcessConfiguration> LoadConfigurationAsync()
{
try
{
if (!File.Exists(_configFilePath))
{
_logger.LogError("Configuration file not found at {Path}", _configFilePath);
throw new FileNotFoundException($"Configuration file not found: {_configFilePath}");
}
var json = await File.ReadAllTextAsync(_configFilePath);
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
var config = JsonSerializer.Deserialize<ProcessConfiguration>(json, options);
if (config == null || config.Processes == null)
{
throw new InvalidOperationException("Invalid configuration format");
}
config.LastModified = File.GetLastWriteTimeUtc(_configFilePath);
_logger.LogInformation("Loaded configuration with {Count} processes", config.Processes.Count);
return config;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading configuration from {Path}", _configFilePath);
throw;
}
}
private FileSystemWatcher InitializeFileWatcher()
{
var directory = Path.GetDirectoryName(_configFilePath) ?? AppContext.BaseDirectory;
var fileName = Path.GetFileName(_configFilePath);
var watcher = new FileSystemWatcher(directory, fileName)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size,
EnableRaisingEvents = true
};
watcher.Changed += async (sender, e) =>
{
// Debounce file changes
await Task.Delay(500);
try
{
_logger.LogInformation("Configuration file changed, reloading");
await ReloadConfigurationAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling configuration file change");
}
};
return watcher;
}
public void Dispose()
{
_fileWatcher?.Dispose();
_configLock?.Dispose();
}
}

View File

@@ -0,0 +1,167 @@
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MarketAlly.ProcessMonitor.Models;
namespace MarketAlly.ProcessMonitor.Services;
/// <summary>
/// Monitors system and process performance metrics
/// </summary>
public class PerformanceMonitoringService : BackgroundService
{
private readonly ILogger<PerformanceMonitoringService> _logger;
private readonly AppSettings _appSettings;
private readonly PerformanceCounter? _cpuCounter;
private readonly PerformanceCounter? _memoryCounter;
private readonly Dictionary<string, ProcessPerformanceInfo> _processMetrics = new();
public PerformanceMonitoringService(
ILogger<PerformanceMonitoringService> logger,
AppSettings appSettings)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_appSettings = appSettings ?? throw new ArgumentNullException(nameof(appSettings));
if (OperatingSystem.IsWindows() && _appSettings.EnablePerformanceMonitoring)
{
try
{
_cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
_memoryCounter = new PerformanceCounter("Memory", "Available MBytes");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to initialize performance counters");
}
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_appSettings.EnablePerformanceMonitoring)
{
_logger.LogInformation("Performance monitoring is disabled");
return;
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
await CollectMetricsAsync();
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error collecting performance metrics");
}
}
}
private async Task CollectMetricsAsync()
{
try
{
var systemMetrics = GetSystemMetrics();
_logger.LogInformation("System metrics - CPU: {Cpu:F1}%, Memory Available: {Memory}MB",
systemMetrics.CpuUsage, systemMetrics.AvailableMemoryMB);
// Collect process-specific metrics
var processes = Process.GetProcesses();
foreach (var process in processes)
{
try
{
if (_processMetrics.ContainsKey(process.ProcessName))
{
var metrics = GetProcessMetrics(process);
_processMetrics[process.ProcessName] = metrics;
if (metrics.WorkingSetMB > 1000) // Log if process uses more than 1GB
{
_logger.LogWarning("Process {ProcessName} is using {Memory}MB of memory",
process.ProcessName, metrics.WorkingSetMB);
}
}
}
catch
{
// Ignore individual process errors
}
}
await Task.CompletedTask;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to collect metrics");
}
}
private SystemMetrics GetSystemMetrics()
{
var metrics = new SystemMetrics();
if (OperatingSystem.IsWindows() && _cpuCounter != null && _memoryCounter != null)
{
try
{
metrics.CpuUsage = _cpuCounter.NextValue();
metrics.AvailableMemoryMB = (long)_memoryCounter.NextValue();
}
catch { }
}
else
{
// Cross-platform fallback
using var process = Process.GetCurrentProcess();
metrics.AvailableMemoryMB = GC.GetTotalMemory(false) / (1024 * 1024);
}
return metrics;
}
private ProcessPerformanceInfo GetProcessMetrics(Process process)
{
return new ProcessPerformanceInfo
{
ProcessName = process.ProcessName,
ProcessId = process.Id,
WorkingSetMB = process.WorkingSet64 / (1024 * 1024),
ThreadCount = process.Threads.Count,
HandleCount = process.HandleCount
};
}
public Dictionary<string, ProcessPerformanceInfo> GetCurrentMetrics()
{
return new Dictionary<string, ProcessPerformanceInfo>(_processMetrics);
}
public override void Dispose()
{
_cpuCounter?.Dispose();
_memoryCounter?.Dispose();
base.Dispose();
}
}
public class SystemMetrics
{
public double CpuUsage { get; set; }
public long AvailableMemoryMB { get; set; }
}
public class ProcessPerformanceInfo
{
public string ProcessName { get; set; } = string.Empty;
public int ProcessId { get; set; }
public long WorkingSetMB { get; set; }
public int ThreadCount { get; set; }
public int HandleCount { get; set; }
}

179
Services/ProcessManager.cs Normal file
View File

@@ -0,0 +1,179 @@
using System.Diagnostics;
using MarketAlly.ProcessMonitor.Interfaces;
using MarketAlly.ProcessMonitor.Models;
using MarketAlly.ProcessMonitor.Exceptions;
using Microsoft.Extensions.Logging;
using Polly;
using Polly.Retry;
namespace MarketAlly.ProcessMonitor.Services;
/// <summary>
/// Manages process lifecycle operations with retry logic and error handling
/// </summary>
public class ProcessManager : IProcessManager
{
private readonly ILogger<ProcessManager> _logger;
private readonly IProcessValidator _validator;
private readonly AsyncRetryPolicy<bool> _retryPolicy;
private readonly SemaphoreSlim _startSemaphore;
public ProcessManager(ILogger<ProcessManager> logger, IProcessValidator validator, AppSettings settings)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_validator = validator ?? throw new ArgumentNullException(nameof(validator));
_startSemaphore = new SemaphoreSlim(settings.MaxConcurrentStarts);
_retryPolicy = Policy<bool>
.Handle<Exception>()
.WaitAndRetryAsync(
3,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timeSpan, retryCount, context) =>
{
_logger.LogWarning(outcome.Exception,
"Retry {RetryCount} after {TimeSpan}s for process {ProcessName}",
retryCount, timeSpan.TotalSeconds, context["ProcessName"]);
});
}
public async Task<bool> StartProcessAsync(ProcessInfo processInfo, bool openInNewWindow, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(processInfo);
var validationResult = _validator.ValidateProcessInfo(processInfo);
if (!validationResult.IsValid)
{
_logger.LogError("Process validation failed for {ProcessName}: {Errors}",
processInfo.Name, string.Join(", ", validationResult.Errors));
throw new ProcessValidationException($"Validation failed: {string.Join(", ", validationResult.Errors)}");
}
await _startSemaphore.WaitAsync(cancellationToken);
try
{
return await _retryPolicy.ExecuteAsync(async (context) =>
{
_logger.LogInformation("Starting process {ProcessName} from {ProcessPath}",
processInfo.Name, processInfo.Path);
var startInfo = new ProcessStartInfo
{
FileName = processInfo.Path,
Arguments = processInfo.Arguments ?? string.Empty,
WorkingDirectory = processInfo.WorkingDirectory ?? Path.GetDirectoryName(processInfo.Path),
CreateNoWindow = !openInNewWindow,
UseShellExecute = openInNewWindow,
RedirectStandardOutput = !openInNewWindow,
RedirectStandardError = !openInNewWindow
};
if (processInfo.EnvironmentVariables != null)
{
foreach (var kvp in processInfo.EnvironmentVariables)
{
startInfo.Environment[kvp.Key] = kvp.Value;
}
}
try
{
var process = Process.Start(startInfo);
if (process == null)
{
throw new ProcessStartException($"Failed to start process {processInfo.Name}");
}
_logger.LogInformation("Successfully started process {ProcessName} with PID {ProcessId}",
processInfo.Name, process.Id);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start process {ProcessName}", processInfo.Name);
throw new ProcessStartException($"Failed to start process {processInfo.Name}", ex);
}
}, new Context { ["ProcessName"] = processInfo.Name });
}
finally
{
_startSemaphore.Release();
}
}
public async Task EnsureProcessRunningAsync(ProcessInfo processInfo, int desiredCount, bool openInNewWindow, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(processInfo);
var runningCount = GetRunningProcessCount(processInfo.Name);
var toStart = Math.Max(0, desiredCount - runningCount);
if (toStart == 0)
{
_logger.LogDebug("Process {ProcessName} already has {Count} instances running",
processInfo.Name, runningCount);
return;
}
_logger.LogInformation("Starting {Count} instances of {ProcessName}", toStart, processInfo.Name);
var tasks = new List<Task<bool>>();
for (int i = 0; i < toStart; i++)
{
tasks.Add(StartProcessAsync(processInfo, openInNewWindow, cancellationToken));
}
await Task.WhenAll(tasks);
}
public int GetRunningProcessCount(string processName)
{
try
{
var processes = Process.GetProcessesByName(processName);
return processes.Length;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting process count for {ProcessName}", processName);
return 0;
}
}
public async Task<bool> StopProcessAsync(string processName, CancellationToken cancellationToken = default)
{
try
{
var processes = Process.GetProcessesByName(processName);
if (processes.Length == 0)
{
_logger.LogInformation("No running instances of {ProcessName} found", processName);
return true;
}
foreach (var process in processes)
{
try
{
process.Kill();
await process.WaitForExitAsync(cancellationToken);
_logger.LogInformation("Stopped process {ProcessName} with PID {ProcessId}",
processName, process.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stop process {ProcessName} with PID {ProcessId}",
processName, process.Id);
}
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error stopping process {ProcessName}", processName);
return false;
}
}
}

View File

@@ -0,0 +1,156 @@
using MarketAlly.ProcessMonitor.Interfaces;
using MarketAlly.ProcessMonitor.Models;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MarketAlly.ProcessMonitor.Services;
/// <summary>
/// Background service that monitors and manages processes
/// </summary>
public class ProcessMonitorService : BackgroundService
{
private readonly ILogger<ProcessMonitorService> _logger;
private readonly IProcessManager _processManager;
private readonly IScheduler _scheduler;
private readonly IConfigurationService _configurationService;
private readonly AppSettings _appSettings;
private readonly Dictionary<string, bool> _initializedProcesses = new();
private ProcessConfiguration? _currentConfiguration;
public ProcessMonitorService(
ILogger<ProcessMonitorService> logger,
IProcessManager processManager,
IScheduler scheduler,
IConfigurationService configurationService)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_processManager = processManager ?? throw new ArgumentNullException(nameof(processManager));
_scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
_configurationService = configurationService ?? throw new ArgumentNullException(nameof(configurationService));
_appSettings = _configurationService.GetAppSettings();
// Subscribe to configuration changes
_configurationService.ConfigurationChanged += OnConfigurationChanged;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Process Monitor Service starting");
// Ask user about window preference once at startup
var openInNewWindow = await GetUserPreferenceAsync();
while (!stoppingToken.IsCancellationRequested)
{
try
{
var configuration = await _configurationService.GetConfigurationAsync();
if (_currentConfiguration == null ||
configuration.LastModified != _currentConfiguration.LastModified)
{
_currentConfiguration = configuration;
await ProcessConfigurationAsync(configuration, openInNewWindow, stoppingToken);
}
await Task.Delay(TimeSpan.FromSeconds(_appSettings.MonitoringIntervalSeconds), stoppingToken);
}
catch (OperationCanceledException)
{
// Expected when cancellation is requested
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in process monitoring loop");
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); // Wait before retry
}
}
_logger.LogInformation("Process Monitor Service stopping");
}
private async Task<bool> GetUserPreferenceAsync()
{
// In production, this could be read from configuration
// For now, keeping the interactive prompt
await Task.Run(() =>
{
Console.WriteLine("Do you want to start processes in a new window? (yes/no)");
Console.WriteLine("This preference will be used for all processes during this session.");
});
string? userInput = await Task.Run(() => Console.ReadLine());
return userInput?.ToLower() == "yes";
}
private async Task ProcessConfigurationAsync(
ProcessConfiguration configuration,
bool openInNewWindow,
CancellationToken cancellationToken)
{
foreach (var processInfo in configuration.Processes.Where(p => p.Enable))
{
if (cancellationToken.IsCancellationRequested) break;
try
{
var isFirstRun = !_initializedProcesses.ContainsKey(processInfo.Name);
if (isFirstRun)
{
_initializedProcesses[processInfo.Name] = true;
// Schedule if it has interval
if (processInfo.Interval.HasValue)
{
_scheduler.ScheduleRepeatedExecution(processInfo, openInNewWindow);
}
// Schedule if it has specific time
else if (!string.IsNullOrEmpty(processInfo.Time))
{
_scheduler.ScheduleProcessStart(processInfo, openInNewWindow);
}
}
// Always ensure continuous processes are running
if (string.IsNullOrEmpty(processInfo.Time) && !processInfo.Interval.HasValue)
{
await _processManager.EnsureProcessRunningAsync(
processInfo,
processInfo.Count,
openInNewWindow,
cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing configuration for {ProcessName}", processInfo.Name);
}
}
}
private void OnConfigurationChanged(object? sender, ProcessConfiguration newConfiguration)
{
_logger.LogInformation("Configuration changed, updating process monitoring");
// Cancel tasks for processes that are no longer enabled
var disabledProcesses = _initializedProcesses.Keys
.Where(name => !newConfiguration.Processes.Any(p => p.Name == name && p.Enable))
.ToList();
foreach (var processName in disabledProcesses)
{
_scheduler.CancelScheduledTasks(processName);
_initializedProcesses.Remove(processName);
_logger.LogInformation("Cancelled monitoring for disabled process: {ProcessName}", processName);
}
}
public override void Dispose()
{
_configurationService.ConfigurationChanged -= OnConfigurationChanged;
base.Dispose();
}
}

View File

@@ -0,0 +1,168 @@
using System.Collections.Concurrent;
using MarketAlly.ProcessMonitor.Interfaces;
using MarketAlly.ProcessMonitor.Models;
using Microsoft.Extensions.Logging;
namespace MarketAlly.ProcessMonitor.Services;
/// <summary>
/// Handles process scheduling with proper cleanup and error handling
/// </summary>
public class ProcessScheduler : IScheduler, IDisposable
{
private readonly ILogger<ProcessScheduler> _logger;
private readonly IProcessManager _processManager;
private readonly ConcurrentDictionary<string, List<Timer>> _scheduledTasks;
private bool _disposed;
public ProcessScheduler(ILogger<ProcessScheduler> logger, IProcessManager processManager)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_processManager = processManager ?? throw new ArgumentNullException(nameof(processManager));
_scheduledTasks = new ConcurrentDictionary<string, List<Timer>>();
}
public void ScheduleProcessStart(ProcessInfo processInfo, bool openInNewWindow)
{
ArgumentNullException.ThrowIfNull(processInfo);
if (string.IsNullOrWhiteSpace(processInfo.Time))
{
_logger.LogWarning("Cannot schedule process {ProcessName} without a time", processInfo.Name);
return;
}
try
{
var scheduledTime = DateTime.Today.Add(TimeSpan.Parse(processInfo.Time));
var delay = scheduledTime - DateTime.Now;
if (delay < TimeSpan.Zero)
{
delay = delay.Add(TimeSpan.FromDays(1));
scheduledTime = scheduledTime.AddDays(1);
}
var timer = new Timer(async _ =>
{
try
{
var runningCount = _processManager.GetRunningProcessCount(processInfo.Name);
if (runningCount == 0)
{
await _processManager.StartProcessAsync(processInfo, openInNewWindow);
_logger.LogInformation("Scheduled process {ProcessName} started at {Time}",
processInfo.Name, DateTime.Now);
}
else
{
_logger.LogInformation("Process {ProcessName} already running, skipping scheduled start",
processInfo.Name);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in scheduled start for process {ProcessName}", processInfo.Name);
}
}, null, delay, Timeout.InfiniteTimeSpan);
AddScheduledTask(processInfo.Name, timer);
_logger.LogInformation("Scheduled {ProcessName} to start at {ScheduledTime}",
processInfo.Name, scheduledTime);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to schedule process {ProcessName}", processInfo.Name);
throw;
}
}
public void ScheduleRepeatedExecution(ProcessInfo processInfo, bool openInNewWindow)
{
ArgumentNullException.ThrowIfNull(processInfo);
if (!processInfo.Interval.HasValue || processInfo.Interval.Value <= 0)
{
_logger.LogWarning("Cannot schedule repeated execution for {ProcessName} without valid interval",
processInfo.Name);
return;
}
try
{
var timer = new Timer(async _ =>
{
try
{
var runningCount = _processManager.GetRunningProcessCount(processInfo.Name);
if (runningCount == 0)
{
await _processManager.StartProcessAsync(processInfo, openInNewWindow);
_logger.LogInformation("Repeated execution: started {ProcessName} at {Time}",
processInfo.Name, DateTime.Now);
}
else
{
_logger.LogDebug("Process {ProcessName} already running, skipping interval start",
processInfo.Name);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in repeated execution for process {ProcessName}", processInfo.Name);
}
}, null, TimeSpan.Zero, TimeSpan.FromMinutes(processInfo.Interval.Value));
AddScheduledTask(processInfo.Name, timer);
_logger.LogInformation("Scheduled {ProcessName} to run every {Interval} minutes",
processInfo.Name, processInfo.Interval.Value);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to schedule repeated execution for process {ProcessName}", processInfo.Name);
throw;
}
}
public void CancelScheduledTasks(string processName)
{
if (_scheduledTasks.TryRemove(processName, out var timers))
{
foreach (var timer in timers)
{
timer?.Dispose();
}
_logger.LogInformation("Cancelled all scheduled tasks for {ProcessName}", processName);
}
}
private void AddScheduledTask(string processName, Timer timer)
{
_scheduledTasks.AddOrUpdate(processName,
new List<Timer> { timer },
(key, list) =>
{
list.Add(timer);
return list;
});
}
public void Dispose()
{
if (_disposed) return;
foreach (var kvp in _scheduledTasks)
{
foreach (var timer in kvp.Value)
{
timer?.Dispose();
}
}
_scheduledTasks.Clear();
_disposed = true;
_logger.LogInformation("ProcessScheduler disposed");
}
}

View File

@@ -0,0 +1,188 @@
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text.RegularExpressions;
using MarketAlly.ProcessMonitor.Interfaces;
using MarketAlly.ProcessMonitor.Models;
using Microsoft.Extensions.Logging;
namespace MarketAlly.ProcessMonitor.Services;
/// <summary>
/// Validates process configurations for security and correctness
/// </summary>
public class ProcessValidator : IProcessValidator
{
private readonly ILogger<ProcessValidator> _logger;
private readonly AppSettings _appSettings;
private readonly HashSet<string> _allowedPaths;
private static readonly Regex TimeFormatRegex = new(@"^([01]?[0-9]|2[0-3]):[0-5][0-9]$", RegexOptions.Compiled);
public ProcessValidator(ILogger<ProcessValidator> logger, AppSettings appSettings)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_appSettings = appSettings ?? throw new ArgumentNullException(nameof(appSettings));
_allowedPaths = new HashSet<string>(_appSettings.AllowedPaths ?? new List<string>(), StringComparer.OrdinalIgnoreCase);
}
public bool ValidateProcessPath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
_logger.LogWarning("Process path is null or empty");
return false;
}
try
{
// Ensure absolute path
if (!Path.IsPathRooted(path))
{
_logger.LogWarning("Process path is not absolute: {Path}", path);
return false;
}
// Normalize path
var normalizedPath = Path.GetFullPath(path);
// Check if file exists
if (!File.Exists(normalizedPath))
{
_logger.LogWarning("Process file does not exist: {Path}", normalizedPath);
return false;
}
// Check against whitelist if enabled
if (_appSettings.EnablePathValidation && _allowedPaths.Count > 0)
{
var isAllowed = _allowedPaths.Any(allowedPath =>
normalizedPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase));
if (!isAllowed)
{
_logger.LogWarning("Process path not in allowed list: {Path}", normalizedPath);
return false;
}
}
// Verify it's an executable
var extension = Path.GetExtension(normalizedPath).ToLowerInvariant();
var executableExtensions = new[] { ".exe", ".bat", ".cmd", ".ps1", ".sh" };
if (!executableExtensions.Contains(extension))
{
_logger.LogWarning("File is not an executable: {Path}", normalizedPath);
return false;
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error validating process path: {Path}", path);
return false;
}
}
public ValidationResult ValidateProcessInfo(ProcessInfo processInfo)
{
var errors = new List<string>();
if (processInfo == null)
{
errors.Add("Process info is null");
return new ValidationResult(false, errors);
}
// Validate name
if (string.IsNullOrWhiteSpace(processInfo.Name))
{
errors.Add("Process name is required");
}
else if (processInfo.Name.Length > 260)
{
errors.Add("Process name is too long");
}
// Validate path
if (!ValidateProcessPath(processInfo.Path))
{
errors.Add($"Invalid process path: {processInfo.Path}");
}
// Validate count
if (processInfo.Count < 0 || processInfo.Count > 100)
{
errors.Add($"Process count must be between 0 and 100, got {processInfo.Count}");
}
// Validate time format
if (!string.IsNullOrWhiteSpace(processInfo.Time) && !TimeFormatRegex.IsMatch(processInfo.Time))
{
errors.Add($"Invalid time format: {processInfo.Time}. Expected HH:mm");
}
// Validate interval
if (processInfo.Interval.HasValue && processInfo.Interval.Value <= 0)
{
errors.Add($"Interval must be positive, got {processInfo.Interval.Value}");
}
// Validate working directory
if (!string.IsNullOrWhiteSpace(processInfo.WorkingDirectory))
{
if (!Directory.Exists(processInfo.WorkingDirectory))
{
errors.Add($"Working directory does not exist: {processInfo.WorkingDirectory}");
}
}
// Validate arguments for potential injection
if (!string.IsNullOrWhiteSpace(processInfo.Arguments))
{
var dangerousPatterns = new[] { ";", "|", "&", "`", "$(" };
if (dangerousPatterns.Any(pattern => processInfo.Arguments.Contains(pattern)))
{
errors.Add("Arguments contain potentially dangerous characters");
}
}
return new ValidationResult(errors.Count == 0, errors);
}
public async Task<bool> CheckPermissionsAsync(string path)
{
try
{
// Check if we can read the file
using (var stream = File.OpenRead(path))
{
// File is readable
}
// Check if running as administrator (Windows)
if (OperatingSystem.IsWindows())
{
var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
var isAdmin = principal.IsInRole(WindowsBuiltInRole.Administrator);
if (!isAdmin)
{
_logger.LogWarning("Not running as administrator, some processes may fail to start");
}
}
return await Task.FromResult(true);
}
catch (UnauthorizedAccessException)
{
_logger.LogError("No permission to access file: {Path}", path);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking permissions for: {Path}", path);
return false;
}
}
}

View File

@@ -0,0 +1,142 @@
using FluentAssertions;
using MarketAlly.ProcessMonitor.Interfaces;
using MarketAlly.ProcessMonitor.Models;
using MarketAlly.ProcessMonitor.Services;
using MarketAlly.ProcessMonitor.Exceptions;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace MarketAlly.ProcessMonitor.Tests.Unit;
[TestClass]
public class ProcessManagerTests
{
private Mock<ILogger<ProcessManager>> _loggerMock = null!;
private Mock<IProcessValidator> _validatorMock = null!;
private ProcessManager _processManager = null!;
private AppSettings _appSettings = null!;
[TestInitialize]
public void Setup()
{
_loggerMock = new Mock<ILogger<ProcessManager>>();
_validatorMock = new Mock<IProcessValidator>();
_appSettings = new AppSettings { MaxConcurrentStarts = 5 };
_processManager = new ProcessManager(_loggerMock.Object, _validatorMock.Object, _appSettings);
}
[TestMethod]
public async Task StartProcessAsync_WithValidConfig_LogsSuccess()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "test",
Path = @"C:\test.exe",
Enable = true
};
_validatorMock.Setup(v => v.ValidateProcessInfo(It.IsAny<ProcessInfo>()))
.Returns(new ValidationResult(true, new List<string>()));
// Act & Assert
// We can't actually start a process in unit tests, so we verify the validation occurs
try
{
await _processManager.StartProcessAsync(processInfo, false);
}
catch (ProcessStartException)
{
// Expected when the process doesn't exist
}
_validatorMock.Verify(v => v.ValidateProcessInfo(processInfo), Times.Once);
}
[TestMethod]
public async Task StartProcessAsync_WithInvalidConfig_ThrowsException()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "invalid",
Path = "invalid_path",
Enable = true
};
_validatorMock.Setup(v => v.ValidateProcessInfo(It.IsAny<ProcessInfo>()))
.Returns(new ValidationResult(false, new List<string> { "Invalid path" }));
// Act & Assert
await Assert.ThrowsExceptionAsync<ProcessValidationException>(
async () => await _processManager.StartProcessAsync(processInfo, false));
}
[TestMethod]
public async Task EnsureProcessRunningAsync_LogsCorrectMessage()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "test",
Path = @"C:\test.exe",
Count = 3,
Enable = true
};
_validatorMock.Setup(v => v.ValidateProcessInfo(It.IsAny<ProcessInfo>()))
.Returns(new ValidationResult(true, new List<string>()));
// Act
try
{
await _processManager.EnsureProcessRunningAsync(processInfo, 3, false);
}
catch (ProcessStartException)
{
// Expected when the process doesn't exist
}
// Assert
_loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Starting 3 instances")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[TestMethod]
public void GetRunningProcessCount_ReturnsCorrectCount()
{
// Arrange & Act
var count = _processManager.GetRunningProcessCount("System");
// Assert
count.Should().BeGreaterOrEqualTo(0);
}
[TestMethod]
public async Task StopProcessAsync_ReturnsTrue_WhenNoProcessesRunning()
{
// Arrange
var processName = "nonexistent_process";
// Act
var result = await _processManager.StopProcessAsync(processName);
// Assert
result.Should().BeTrue();
_loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("No running instances")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
}

View File

@@ -0,0 +1,168 @@
using FluentAssertions;
using MarketAlly.ProcessMonitor.Interfaces;
using MarketAlly.ProcessMonitor.Models;
using MarketAlly.ProcessMonitor.Services;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace MarketAlly.ProcessMonitor.Tests.Unit;
[TestClass]
public class ProcessSchedulerTests
{
private Mock<ILogger<ProcessScheduler>> _loggerMock = null!;
private Mock<IProcessManager> _processManagerMock = null!;
private ProcessScheduler _scheduler = null!;
[TestInitialize]
public void Setup()
{
_loggerMock = new Mock<ILogger<ProcessScheduler>>();
_processManagerMock = new Mock<IProcessManager>();
_scheduler = new ProcessScheduler(_loggerMock.Object, _processManagerMock.Object);
}
[TestCleanup]
public void Cleanup()
{
_scheduler?.Dispose();
}
[TestMethod]
public void ScheduleProcessStart_WithValidTime_SchedulesProcess()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "TestProcess",
Path = "C:\\test.exe",
Time = DateTime.Now.AddHours(1).ToString("HH:mm"),
Enable = true
};
// Act
_scheduler.ScheduleProcessStart(processInfo, false);
// Assert
_loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Scheduled TestProcess to start at")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[TestMethod]
public void ScheduleProcessStart_WithoutTime_DoesNotSchedule()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "TestProcess",
Path = "C:\\test.exe",
Time = null,
Enable = true
};
// Act
_scheduler.ScheduleProcessStart(processInfo, false);
// Assert
_loggerMock.Verify(
l => l.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Cannot schedule process")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[TestMethod]
public void ScheduleRepeatedExecution_WithValidInterval_SchedulesProcess()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "TestProcess",
Path = "C:\\test.exe",
Interval = 15,
Enable = true
};
_processManagerMock.Setup(m => m.GetRunningProcessCount(It.IsAny<string>()))
.Returns(0);
// Act
_scheduler.ScheduleRepeatedExecution(processInfo, false);
// Assert
_loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Scheduled TestProcess to run every 15 minutes")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[TestMethod]
public void CancelScheduledTasks_RemovesAllTasksForProcess()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "TestProcess",
Path = "C:\\test.exe",
Interval = 15,
Enable = true
};
_scheduler.ScheduleRepeatedExecution(processInfo, false);
// Act
_scheduler.CancelScheduledTasks("TestProcess");
// Assert
_loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Cancelled all scheduled tasks for TestProcess")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[TestMethod]
public void Dispose_DisposesAllTimers()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "TestProcess",
Path = "C:\\test.exe",
Interval = 15,
Enable = true
};
_scheduler.ScheduleRepeatedExecution(processInfo, false);
// Act
_scheduler.Dispose();
// Assert
_loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("ProcessScheduler disposed")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
}

View File

@@ -0,0 +1,158 @@
using FluentAssertions;
using MarketAlly.ProcessMonitor.Models;
using MarketAlly.ProcessMonitor.Services;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace MarketAlly.ProcessMonitor.Tests.Unit;
[TestClass]
public class ProcessValidatorTests
{
private Mock<ILogger<ProcessValidator>> _loggerMock = null!;
private ProcessValidator _validator = null!;
private AppSettings _appSettings = null!;
[TestInitialize]
public void Setup()
{
_loggerMock = new Mock<ILogger<ProcessValidator>>();
_appSettings = new AppSettings
{
EnablePathValidation = true,
AllowedPaths = new List<string> { @"C:\Windows", @"C:\Program Files" }
};
_validator = new ProcessValidator(_loggerMock.Object, _appSettings);
}
[TestMethod]
public void ValidateProcessPath_WithNullPath_ReturnsFalse()
{
// Act
var result = _validator.ValidateProcessPath(null!);
// Assert
result.Should().BeFalse();
}
[TestMethod]
public void ValidateProcessPath_WithRelativePath_ReturnsFalse()
{
// Act
var result = _validator.ValidateProcessPath("relative/path.exe");
// Assert
result.Should().BeFalse();
}
[TestMethod]
public void ValidateProcessPath_WithNonExistentFile_ReturnsFalse()
{
// Act
var result = _validator.ValidateProcessPath(@"C:\nonexistent\file.exe");
// Assert
result.Should().BeFalse();
}
[TestMethod]
public void ValidateProcessPath_WithNonExecutableFile_ReturnsFalse()
{
// Act
var result = _validator.ValidateProcessPath(@"C:\Windows\System32\drivers\etc\hosts");
// Assert
result.Should().BeFalse();
}
[TestMethod]
public void ValidateProcessInfo_WithValidInfo_ReturnsValid()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "TestProcess",
Path = @"C:\Windows\System32\notepad.exe",
Count = 1,
Enable = true
};
// Act
var result = _validator.ValidateProcessInfo(processInfo);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[TestMethod]
public void ValidateProcessInfo_WithInvalidTime_ReturnsError()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "TestProcess",
Path = @"C:\Windows\System32\notepad.exe",
Time = "25:00", // Invalid time
Enable = true
};
// Act
var result = _validator.ValidateProcessInfo(processInfo);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Invalid time format"));
}
[TestMethod]
public void ValidateProcessInfo_WithNegativeInterval_ReturnsError()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "TestProcess",
Path = @"C:\Windows\System32\notepad.exe",
Interval = -5,
Enable = true
};
// Act
var result = _validator.ValidateProcessInfo(processInfo);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("Interval must be positive"));
}
[TestMethod]
public void ValidateProcessInfo_WithDangerousArguments_ReturnsError()
{
// Arrange
var processInfo = new ProcessInfo
{
Name = "TestProcess",
Path = @"C:\Windows\System32\notepad.exe",
Arguments = "test; rm -rf /",
Enable = true
};
// Act
var result = _validator.ValidateProcessInfo(processInfo);
// Assert
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.Contains("dangerous characters"));
}
[TestMethod]
public async Task CheckPermissionsAsync_WithAccessibleFile_ReturnsTrue()
{
// Act
var result = await _validator.CheckPermissionsAsync(@"C:\Windows\System32\notepad.exe");
// Assert
result.Should().BeTrue();
}
}

23
appsettings.json Normal file
View File

@@ -0,0 +1,23 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ProcessMonitor": {
"MonitoringIntervalSeconds": 10,
"EnableDebugLogging": false,
"LogRetentionDays": 30,
"AllowedPaths": [
"C:\\Windows\\System32",
"C:\\Program Files",
"C:\\Program Files (x86)",
"C:\\test.exe"
],
"EnablePathValidation": true,
"EnablePerformanceMonitoring": true,
"MaxConcurrentStarts": 5
}
}

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
version: '3.8'
services:
processmonitor:
build:
context: .
dockerfile: Dockerfile
image: marketally/processmonitor:latest
container_name: process-monitor
restart: unless-stopped
volumes:
- ./processlist.json:/app/processlist.json:ro
- ./appsettings.json:/app/appsettings.json:ro
- ./logs:/app/logs
environment:
- DOTNET_ENVIRONMENT=Production
- ProcessMonitor__EnableDebugLogging=false
- ProcessMonitor__MonitoringIntervalSeconds=10
networks:
- processmonitor-network
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
networks:
processmonitor-network:
driver: bridge

View File

@@ -2,23 +2,61 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>false</PublishAot>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<None Remove="processlist.json" />
<None Remove="appsettings.json" />
<None Remove="appsettings.*.json" />
</ItemGroup>
<ItemGroup>
<Content Include="processlist.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="appsettings.*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageReference Include="Polly" Version="8.5.0" />
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="MSTest.TestAdapter" Version="3.6.4" />
<PackageReference Include="MSTest.TestFramework" Version="3.6.4" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="coverlet.collector" Version="6.0.2" />
</ItemGroup>
</Project>

View File

@@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34310.174
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "marketally.processmonitor", "marketally.processmonitor.csproj", "{0DC18C8D-FF6C-4A8A-9215-3D99C3B19F98}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MarketAlly.ProcessMonitor", "MarketAlly.ProcessMonitor.csproj", "{0DC18C8D-FF6C-4A8A-9215-3D99C3B19F98}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution

View File

@@ -1,12 +1,17 @@
{
"version": "1.0",
"processes": [
{
"name": "",
"count": 3,
"enable": true,
"name": "ExampleProcess",
"path": "C:\\Windows\\System32\\notepad.exe",
"count": 1,
"enable": false,
"interval": 5,
"time": "15:25",
"path": ""
"arguments": "",
"workingDirectory": "",
"maxRetries": 3,
"retryDelaySeconds": 5
}
]
}