Compare commits

...

17 Commits

Author SHA1 Message Date
Leendert de Borst
aca607e579 Merge pull request #515 from lanedirt/514-prepare-0102-release
Bump version to 0.10.2
2025-01-03 22:30:47 +01:00
Leendert de Borst
ed053422ba Update StatusHostedService.cs (#512) 2025-01-03 22:15:27 +01:00
Leendert de Borst
955b8638ce Bump version (#514) 2025-01-03 21:50:14 +01:00
Leendert de Borst
1d8883cc94 Merge pull request #513 from lanedirt/512-task-runner-cleanup-jobs-do-not-run
Maintenance tasks do not run after migration to PostgreSQL
2025-01-03 21:45:04 +01:00
Leendert de Borst
48281f92e6 Refactor to reduce complexity (#512) 2025-01-03 21:29:42 +01:00
Leendert de Borst
f19db2c010 Refactor StatusWorker to prevent race conditions and improve stability (#512) 2025-01-03 20:38:13 +01:00
Leendert de Borst
f0d397c8af Add cancellation token check to worker start and stop wait (#512) 2025-01-03 16:18:37 +01:00
Leendert de Borst
fafa51d787 Update integration tests (#512) 2025-01-03 16:08:09 +01:00
Leendert de Borst
202151e4f1 Update SmtpServer TestHostBuilder to be compatible with integration and E2E tests (#512) 2025-01-03 15:36:09 +01:00
Leendert de Borst
c123edccd4 Refactor integration test TestHostBuilder setup to shared abstract class (#512) 2025-01-03 15:22:47 +01:00
Leendert de Borst
50cab3a2f3 Show full error when maintenance task fails to start (#512) 2025-01-03 12:44:55 +01:00
Leendert de Borst
0184e32e6d Update migration to reset task runner job sequence (#512) 2025-01-03 12:44:40 +01:00
Leendert de Borst
d73d4e90e0 Make admin password for dev always override existing password (#512) 2025-01-03 11:58:33 +01:00
Leendert de Borst
06d38842f5 Add dev database import/export support (#512) 2025-01-03 11:56:55 +01:00
Leendert de Borst
b0748316ff Merge pull request #511 from lanedirt/510-admin-password-hash-method-in-installcli-does-not-pass-required-arguments
Admin password hash method in installcli does not pass required arguments
2025-01-01 16:18:36 +01:00
Leendert de Borst
8f8b4af3c9 Update install.sh (#510) 2025-01-01 16:18:15 +01:00
Leendert de Borst
11bf183cbb Update install.sh (#510) 2025-01-01 16:17:41 +01:00
20 changed files with 720 additions and 425 deletions

3
.gitignore vendored
View File

@@ -415,3 +415,6 @@ docs/.bundle
# Database files
database/postgres
database/postgres-dev
# Temp files
temp

View File

@@ -1,5 +1,5 @@
#!/bin/bash
# @version 0.10.0
# @version 0.10.1
# Repository information used for downloading files and images from GitHub
REPO_OWNER="lanedirt"
@@ -60,6 +60,7 @@ show_usage() {
printf "Options:\n"
printf " --verbose Show detailed output\n"
printf " -y, --yes Automatic yes to prompts\n"
printf " --dev Target development database for db import/export operations\n"
printf " --help Show this help message\n"
printf "\n"
@@ -71,6 +72,7 @@ parse_args() {
VERBOSE=false
FORCE_YES=false
COMMAND_ARG=""
DEV_DB=false
if [ $# -eq 0 ]; then
show_usage
@@ -189,6 +191,10 @@ parse_args() {
FORCE_YES=true
shift
;;
--dev)
DEV_DB=true
shift
;;
*)
echo "Unknown option: $1"
show_usage
@@ -526,13 +532,13 @@ generate_admin_password() {
fi
)
fi
HASH=$(docker run --rm installcli "$PASSWORD")
HASH=$(docker run --rm installcli hash-password "$PASSWORD")
if [ -z "$HASH" ]; then
printf "${RED}> Error: Failed to generate password hash${NC}\n"
exit 1
fi
else
HASH=$(docker run --rm ${GITHUB_CONTAINER_REGISTRY}-installcli:latest "$PASSWORD")
HASH=$(docker run --rm ${GITHUB_CONTAINER_REGISTRY}-installcli:latest hash-password "$PASSWORD")
if [ -z "$HASH" ]; then
printf "${RED}> Error: Failed to generate password hash${NC}\n"
exit 1
@@ -1810,30 +1816,48 @@ handle_db_export() {
# Check if output redirection is present
if [ -t 1 ]; then
printf "${RED}Error: Output redirection is required.${NC}\n" >&2
printf "Usage: ./install.sh db-export > backup.sql.gz\n" >&2
printf "Usage: ./install.sh db-export [--dev] > backup.sql.gz\n" >&2
printf "\n" >&2
printf "Options:\n" >&2
printf " --dev Export from development database\n" >&2
printf "\n" >&2
printf "Example:\n" >&2
printf " ./install.sh db-export > my_backup_$(date +%Y%m%d).sql.gz\n" >&2
printf " ./install.sh db-export --dev > my_dev_backup_$(date +%Y%m%d).sql.gz\n" >&2
exit 1
fi
# Check if containers are running
if ! docker compose ps --quiet 2>/dev/null | grep -q .; then
printf "${RED}Error: AliasVault containers are not running. Start them first with: ./install.sh start${NC}\n" >&2
exit 1
if [ "$DEV_DB" = true ]; then
# Check if dev containers are running
if ! docker compose -f docker-compose.dev.yml -p aliasvault-dev ps postgres-dev --quiet 2>/dev/null | grep -q .; then
printf "${RED}Error: Development database container is not running. Start it first with: ./install.sh configure-dev-db${NC}\n" >&2
exit 1
fi
# Check if postgres-dev container is healthy
if ! docker compose -f docker-compose.dev.yml -p aliasvault-dev ps postgres-dev | grep -q "healthy"; then
printf "${RED}Error: Development PostgreSQL container is not healthy. Please check the logs.${NC}\n" >&2
exit 1
fi
printf "${CYAN}> Exporting development database...${NC}\n" >&2
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec postgres-dev pg_dump -U aliasvault aliasvault | gzip
else
# Production database export logic
if ! docker compose ps --quiet 2>/dev/null | grep -q .; then
printf "${RED}Error: AliasVault containers are not running. Start them first with: ./install.sh start${NC}\n" >&2
exit 1
fi
if ! docker compose ps postgres | grep -q "healthy"; then
printf "${RED}Error: PostgreSQL container is not healthy. Please check the logs with: docker compose logs postgres${NC}\n" >&2
exit 1
fi
printf "${CYAN}> Exporting production database...${NC}\n" >&2
docker compose exec postgres pg_dump -U aliasvault aliasvault | gzip
fi
# Check if postgres container is healthy
if ! docker compose ps postgres | grep -q "healthy"; then
printf "${RED}Error: PostgreSQL container is not healthy. Please check the logs with: docker compose logs postgres${NC}\n" >&2
exit 1
fi
printf "${CYAN}> Exporting database...${NC}\n" >&2
# Only the actual pg_dump output goes to stdout, everything else to stderr
docker compose exec postgres pg_dump -U aliasvault aliasvault | gzip
if [ $? -eq 0 ]; then
printf "${GREEN}> Database exported successfully.${NC}\n" >&2
else
@@ -1847,22 +1871,36 @@ handle_db_import() {
printf "${YELLOW}+++ Importing Database +++${NC}\n"
# Check if containers are running
if ! docker compose ps postgres | grep -q "healthy"; then
printf "${RED}Error: PostgreSQL container is not healthy.${NC}\n"
exit 1
if [ "$DEV_DB" = true ]; then
if ! docker compose -f docker-compose.dev.yml -p aliasvault-dev ps postgres-dev | grep -q "healthy"; then
printf "${RED}Error: Development PostgreSQL container is not healthy.${NC}\n"
exit 1
fi
else
if ! docker compose ps postgres | grep -q "healthy"; then
printf "${RED}Error: PostgreSQL container is not healthy.${NC}\n"
exit 1
fi
fi
# Check if we're getting input from a pipe
if [ -t 0 ]; then
printf "${RED}Error: No input file provided${NC}\n"
printf "Usage: ./install.sh db-import < backup.sql.gz\n"
printf "Usage: ./install.sh db-import [--dev] < backup.sql.gz\n"
exit 1
fi
# Save stdin to file descriptor 3
exec 3<&0
printf "${RED}Warning: This will DELETE ALL EXISTING DATA in the database.${NC}\n"
printf "${RED}Warning: This will DELETE ALL EXISTING DATA in the "
if [ "$DEV_DB" = true ]; then
printf "development database"
else
printf "database"
fi
printf ".${NC}\n"
if [ "$FORCE_YES" != true ]; then
# Use /dev/tty to read from terminal even when stdin is redirected
if [ -t 1 ] && [ -t 2 ] && [ -e /dev/tty ]; then
@@ -1882,14 +1920,20 @@ handle_db_import() {
fi
fi
printf "${CYAN}> Stopping dependent services...${NC}\n"
if [ "$VERBOSE" = true ]; then
docker compose stop api admin task-runner smtp
else
docker compose stop api admin task-runner smtp > /dev/null 2>&1
if [ "$DEV_DB" != true ]; then
printf "${CYAN}> Stopping dependent services...${NC}\n"
if [ "$VERBOSE" = true ]; then
docker compose stop api admin task-runner smtp
else
docker compose stop api admin task-runner smtp > /dev/null 2>&1
fi
fi
printf "${CYAN}> Importing database...${NC}\n"
printf "${CYAN}> Importing "
if [ "$DEV_DB" = true ]; then
printf "development "
fi
printf "database...${NC}\n"
# Create a temporary file to verify the gzip input
temp_file=$(mktemp)
@@ -1902,18 +1946,30 @@ handle_db_import() {
exit 1
fi
if [ "$VERBOSE" = true ]; then
# Proceed with import
docker compose exec -T postgres psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" && \
docker compose exec -T postgres psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" && \
docker compose exec -T postgres psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" && \
gunzip -c "$temp_file" | docker compose exec -T postgres psql -U aliasvault aliasvault
if [ "$DEV_DB" = true ]; then
if [ "$VERBOSE" = true ]; then
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" && \
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" && \
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" && \
gunzip -c "$temp_file" | docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault aliasvault
else
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" > /dev/null 2>&1 && \
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" > /dev/null 2>&1 && \
docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" > /dev/null 2>&1 && \
gunzip -c "$temp_file" | docker compose -f docker-compose.dev.yml -p aliasvault-dev exec -T postgres-dev psql -U aliasvault aliasvault > /dev/null 2>&1
fi
else
# Suppress all output except errors
docker compose exec -T postgres psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" > /dev/null 2>&1 && \
docker compose exec -T postgres psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" > /dev/null 2>&1 && \
docker compose exec -T postgres psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" > /dev/null 2>&1 && \
gunzip -c "$temp_file" | docker compose exec -T postgres psql -U aliasvault aliasvault > /dev/null 2>&1
if [ "$VERBOSE" = true ]; then
docker compose exec -T postgres psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" && \
docker compose exec -T postgres psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" && \
docker compose exec -T postgres psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" && \
gunzip -c "$temp_file" | docker compose exec -T postgres psql -U aliasvault aliasvault
else
docker compose exec -T postgres psql -U aliasvault postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'aliasvault' AND pid <> pg_backend_pid();" > /dev/null 2>&1 && \
docker compose exec -T postgres psql -U aliasvault postgres -c "DROP DATABASE IF EXISTS aliasvault;" > /dev/null 2>&1 && \
docker compose exec -T postgres psql -U aliasvault postgres -c "CREATE DATABASE aliasvault OWNER aliasvault;" > /dev/null 2>&1 && \
gunzip -c "$temp_file" | docker compose exec -T postgres psql -U aliasvault aliasvault > /dev/null 2>&1
fi
fi
import_status=$?
@@ -1921,11 +1977,13 @@ handle_db_import() {
if [ $import_status -eq 0 ]; then
printf "${GREEN}> Database imported successfully.${NC}\n"
printf "${CYAN}> Starting services...${NC}\n"
if [ "$VERBOSE" = true ]; then
docker compose restart api admin task-runner smtp reverse-proxy
else
docker compose restart api admin task-runner smtp reverse-proxy > /dev/null 2>&1
if [ "$DEV_DB" != true ]; then
printf "${CYAN}> Starting services...${NC}\n"
if [ "$VERBOSE" = true ]; then
docker compose restart api admin task-runner smtp reverse-proxy
else
docker compose restart api admin task-runner smtp reverse-proxy > /dev/null 2>&1
fi
fi
else
printf "${RED}> Import failed. Please check that your backup file is valid.${NC}\n"

View File

@@ -5,10 +5,10 @@
{
<button @onclick="() => ServiceClick(service.Name)"
class="@GetServiceButtonClasses(service) mx-3 inline-flex items-center justify-center rounded-xl px-8 py-2 text-white"
disabled="@(!IsHeartbeatValid(service.LastHeartbeat))"
title="@GetButtonTooltip(service.LastHeartbeat)">
disabled="@(!service.IsHeartBeatValid)"
title="@GetButtonTooltip(service)">
<span>@service.DisplayName</span>
@if (service.IsPending)
@if (service.IsHeartBeatValid && service.CurrentStatus != service.DesiredStatus && !string.IsNullOrEmpty(service.DesiredStatus))
{
<svg class="animate-spin ml-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -54,9 +54,9 @@
{
public string Name { get; set; } = "";
public string DisplayName { get; set; } = "";
public bool Status { get; set; }
public bool IsPending { get; set; }
public DateTime LastHeartbeat { get; set; }
public string CurrentStatus { get; set; } = "";
public string DesiredStatus { get; set; } = "";
public bool IsHeartBeatValid { get; set; }
}
private List<ServiceState> Services { get; set; } = [];
@@ -112,15 +112,23 @@
{
string buttonClass = "cursor-pointer ";
if (!IsHeartbeatValid(service.LastHeartbeat))
if (!service.IsHeartBeatValid)
{
buttonClass += "bg-gray-600";
}
else if (service.Status)
else if (service.CurrentStatus == "Started" && (service.DesiredStatus == string.Empty || service.DesiredStatus == "Started"))
{
buttonClass += "bg-green-600";
}
else
else if (service.CurrentStatus == "Stopping" || (service.DesiredStatus == "Stopped" && service.CurrentStatus != service.DesiredStatus))
{
buttonClass += "bg-red-500";
}
else if (service.CurrentStatus == "Starting" || (service.DesiredStatus == "Started" && service.CurrentStatus != service.DesiredStatus))
{
buttonClass += "bg-emerald-500";
}
else if (service.DesiredStatus == "Stopped" && (service.DesiredStatus == string.Empty || service.DesiredStatus == "Stopped"))
{
buttonClass += "bg-red-600";
}
@@ -131,9 +139,22 @@
/// <summary>
/// Gets the tooltip text for a service button based on its last heartbeat.
/// </summary>
private static string GetButtonTooltip(DateTime lastHeartbeat)
private static string GetButtonTooltip(ServiceState service)
{
return IsHeartbeatValid(lastHeartbeat) ? "" : "Heartbeat offline";
if (!service.IsHeartBeatValid)
{
return "Heartbeat offline";
}
var statusMessages = new Dictionary<string, string>
{
{ "Started", "Service is running" },
{ "Starting", "Service is starting..." },
{ "Stopped", "Service is stopped" },
{ "Stopping", "Service is stopping..." }
};
return statusMessages.GetValueOrDefault(service.CurrentStatus, string.Empty);
}
/// <summary>
@@ -143,18 +164,25 @@
{
var service = Services.First(s => s.Name == serviceName);
if (!IsHeartbeatValid(service.LastHeartbeat))
if (!service.IsHeartBeatValid)
{
return;
}
service.IsPending = true;
// If service not started and not starting, clicking should start it. Otherwise, stop it.
if (service.CurrentStatus == "Started" || service.DesiredStatus == "Started")
{
service.DesiredStatus = "Stopped";
}
else
{
service.DesiredStatus = "Started";
}
StateHasChanged();
service.Status = !service.Status;
await UpdateServiceStatus(serviceName, service.Status);
await UpdateServiceStatus(serviceName, service.DesiredStatus);
service.CurrentStatus = service.DesiredStatus;
service.IsPending = false;
StateHasChanged();
}
@@ -163,7 +191,7 @@
/// </summary>
private async Task InitPage()
{
if (InitInProgress || Services.Any(s => s.IsPending))
if (InitInProgress)
{
return;
}
@@ -179,8 +207,9 @@
var entry = ServiceStatus.Find(x => x.ServiceName == service.Name);
if (entry != null)
{
service.LastHeartbeat = entry.Heartbeat;
service.Status = IsHeartbeatValid(service.LastHeartbeat) && entry.CurrentStatus == "Started";
service.IsHeartBeatValid = IsHeartbeatValid(entry.Heartbeat);
service.CurrentStatus = entry.CurrentStatus;
service.DesiredStatus = entry.DesiredStatus;
}
}
@@ -195,14 +224,13 @@
/// <summary>
/// Updates the status of a service.
/// </summary>
private async Task<bool> UpdateServiceStatus(string serviceName, bool newStatus)
private async Task<bool> UpdateServiceStatus(string serviceName, string desiredStatus)
{
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
var entry = await dbContext.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstOrDefaultAsync();
if (entry != null)
{
string newDesiredStatus = newStatus ? "Started" : "Stopped";
entry.DesiredStatus = newDesiredStatus;
entry.DesiredStatus = desiredStatus;
await dbContext.SaveChangesAsync();
var timeout = DateTime.UtcNow.AddSeconds(30);
@@ -215,7 +243,7 @@
await using var dbContextInner = await DbContextFactory.CreateDbContextAsync();
var check = await dbContextInner.WorkerServiceStatuses.Where(x => x.ServiceName == serviceName).FirstAsync();
if (check.CurrentStatus == newDesiredStatus)
if (check.CurrentStatus == entry.DesiredStatus)
{
return true;
}

View File

@@ -26,17 +26,23 @@
private RegistrationStatisticsCard? _registrationStatisticsCard;
private EmailStatisticsCard? _emailStatisticsCard;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Check if 2FA is enabled. If not, show a one-time warning on the dashboard.
if (!UserService.User().TwoFactorEnabled)
{
GlobalNotificationService.AddWarningMessage("Two-factor authentication is not enabled. It is recommended to enable it in Account Settings for better security.", true);
}
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Check if 2FA is enabled. If not, show a one-time warning on the dashboard.
if (!UserService.User().TwoFactorEnabled)
{
GlobalNotificationService.AddWarningMessage("Two-factor authentication is not enabled. It is recommended to enable it in Account Settings for better security.", true);
}
await RefreshData();
}
}

View File

@@ -162,7 +162,7 @@
}
catch (Exception ex)
{
GlobalNotificationService.AddErrorMessage($"Failed to start maintenance tasks: {ex.Message}", true);
GlobalNotificationService.AddErrorMessage($"Failed to start maintenance tasks: {ex}", true);
}
}

View File

@@ -17,7 +17,7 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ADMIN_PASSWORD_HASH": "AQAAAAIAAYagAAAAEKWfKfa2gh9Z72vjAlnNP1xlME7FsunRznzyrfqFte40FToufRwa3kX8wwDwnEXZag==",
"ADMIN_PASSWORD_GENERATED": "2024-01-01T00:00:00Z",
"ADMIN_PASSWORD_GENERATED": "2030-01-01T00:00:00Z",
"DATA_PROTECTION_CERT_PASS": "Development"
}
},

View File

@@ -670,6 +670,10 @@ video {
margin-left: auto;
}
.mr-1 {
margin-right: 0.25rem;
}
.mr-14 {
margin-right: 3.5rem;
}
@@ -1148,6 +1152,11 @@ video {
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
}
.bg-emerald-500 {
--tw-bg-opacity: 1;
background-color: rgb(16 185 129 / var(--tw-bg-opacity));
}
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
@@ -1498,10 +1507,6 @@ video {
font-weight: 600;
}
.uppercase {
text-transform: uppercase;
}
.leading-6 {
line-height: 1.5rem;
}

View File

@@ -101,23 +101,34 @@ public class TaskRunnerWorker(
{
foreach (var task in tasks)
{
// Check cancellation before each task
stoppingToken.ThrowIfCancellationRequested();
try
{
job.Status = TaskRunnerJobStatus.Running;
await dbContext.SaveChangesAsync(stoppingToken);
await task.ExecuteAsync(stoppingToken);
}
catch (OperationCanceledException)
{
// Handle cancellation gracefully
job.Status = TaskRunnerJobStatus.Canceled;
job.ErrorMessage = "Task execution was canceled.";
await dbContext.SaveChangesAsync(stoppingToken);
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Error executing task {TaskName}", task.Name);
job.ErrorMessage = $"Task {task.Name} failed: {ex.Message}";
job.Status = TaskRunnerJobStatus.Error;
job.ErrorMessage = $"Task {task.Name} failed: {ex.Message}";
await dbContext.SaveChangesAsync(stoppingToken);
break;
}
}
if (job.Status != TaskRunnerJobStatus.Error)
if (job.Status != TaskRunnerJobStatus.Error && job.Status != TaskRunnerJobStatus.Canceled)
{
job.Status = TaskRunnerJobStatus.Finished;
}

View File

@@ -30,7 +30,7 @@ public static class AppInfo
/// <summary>
/// Gets the patch version number.
/// </summary>
public const int VersionPatch = 0;
public const int VersionPatch = 2;
/// <summary>
/// Gets the build number, typically used in CI/CD pipelines.

View File

@@ -27,6 +27,11 @@ public enum TaskRunnerJobStatus
/// </summary>
Finished = 2,
/// <summary>
/// The job has been canceled because the task runner has been stopped.
/// </summary>
Canceled = 8,
/// <summary>
/// The job has failed.
/// </summary>

View File

@@ -0,0 +1,164 @@
// -----------------------------------------------------------------------
// <copyright file="AbstractTestHostBuilder.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
// -----------------------------------------------------------------------
namespace AliasVault.IntegrationTests;
using System.Reflection;
using AliasServerDb;
using AliasServerDb.Configuration;
using AliasVault.Logging;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
/// <summary>
/// Builder class for creating a test host for services in order to run integration tests against them. This class
/// contains common logic such as creating a temporary database.
/// </summary>
public class AbstractTestHostBuilder : IAsyncDisposable
{
/// <summary>
/// The DbContextFactory instance that is created for the test.
/// </summary>
private IAliasServerDbContextFactory _dbContextFactory = null!;
/// <summary>
/// The cached DbContext instance that can be used during the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// The temporary database name for the test.
/// </summary>
private string? _tempDbName;
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public AliasServerDbContext GetDbContext()
{
if (_dbContext != null)
{
return _dbContext;
}
_dbContext = _dbContextFactory.CreateDbContext();
return _dbContext;
}
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public async Task<AliasServerDbContext> GetDbContextAsync()
{
return await _dbContextFactory.CreateDbContextAsync();
}
/// <summary>
/// Disposes of the test host and cleans up the temporary database.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_dbContext != null)
{
await _dbContext.DisposeAsync();
_dbContext = null;
}
if (!string.IsNullOrEmpty(_tempDbName))
{
// Create a connection to 'postgres' database to drop the test database
using var conn =
new NpgsqlConnection(
"Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password");
await conn.OpenAsync();
// First terminate existing connections
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{_tempDbName}';
""";
await cmd.ExecuteNonQueryAsync();
}
// Then drop the database
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
DROP DATABASE IF EXISTS "{_tempDbName}";
""";
await cmd.ExecuteNonQueryAsync();
}
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Creates a new test host builder with test database connection already configured.
/// </summary>
/// <returns>IHost.</returns>
protected IHostBuilder CreateBuilder()
{
_tempDbName = $"aliasdb_test_{Guid.NewGuid()}";
// Create a connection to 'postgres' database to ensure the test database exists
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
using (var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password"))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
CREATE DATABASE "{_tempDbName}";
""";
cmd.ExecuteNonQuery();
}
}
// Create a connection to the new test database
var dbConnection = new NpgsqlConnection($"Host=localhost;Port=5433;Database={_tempDbName};Username=aliasvault;Password=password");
var builder = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Override configuration
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddInMemoryCollection(new Dictionary<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddAliasVaultDatabaseConfiguration(configuration);
services.ConfigureLogging(configuration, Assembly.GetExecutingAssembly().GetName().Name!, "logs");
// Ensure the in-memory database is populated with tables
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
_dbContextFactory = scope.ServiceProvider.GetRequiredService<IAliasServerDbContextFactory>();
var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Database.Migrate();
}
});
return builder;
}
}

View File

@@ -8,81 +8,20 @@
namespace AliasVault.IntegrationTests.SmtpServer;
using System.Data.Common;
using AliasServerDb;
using AliasServerDb.Configuration;
using AliasVault.SmtpService;
using AliasVault.SmtpService.Handlers;
using AliasVault.SmtpService.Workers;
using global::SmtpServer;
using global::SmtpServer.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
/// <summary>
/// Builder class for creating a test host for the SmtpServiceWorker in order to run integration tests against it.
/// </summary>
public class TestHostBuilder : IAsyncDisposable
public class TestHostBuilder : AbstractTestHostBuilder
{
/// <summary>
/// The DbContextFactory instance that is created for the test.
/// </summary>
private IAliasServerDbContextFactory _dbContextFactory = null!;
/// <summary>
/// The cached DbContext instance that can be used during the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// The temporary database name for the test.
/// </summary>
private string? _tempDbName;
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public AliasServerDbContext GetDbContext()
{
if (_dbContext != null)
{
return _dbContext;
}
_dbContext = _dbContextFactory.CreateDbContext();
return _dbContext;
}
/// <summary>
/// Builds the SmtpService test host.
/// </summary>
/// <returns>IHost.</returns>
public IHost Build()
{
_tempDbName = $"aliasdb_test_{Guid.NewGuid()}";
// Create a connection to 'postgres' database to ensure the test database exists
using (var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password"))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
CREATE DATABASE "{_tempDbName}";
""";
cmd.ExecuteNonQuery();
}
}
// Create a connection to the new test database
var dbConnection = new NpgsqlConnection($"Host=localhost;Port=5433;Database={_tempDbName};Username=aliasvault;Password=password");
return Build(dbConnection);
}
/// <summary>
/// Builds the SmtpService test host with a provided database connection.
/// </summary>
@@ -90,102 +29,81 @@ public class TestHostBuilder : IAsyncDisposable
/// <returns>IHost.</returns>
public IHost Build(DbConnection dbConnection)
{
var builder = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Override configuration
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddInMemoryCollection(new Dictionary<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.Build();
// Get base builder with database connection already configured.
var builder = CreateBuilder();
services.AddSingleton<IConfiguration>(configuration);
services.AddSingleton(new Config
// Add specific services for the TestExceptionWorker.
builder.ConfigureServices((context, services) =>
{
// Override database connection with provided connection.
services.Remove(services.First(x => x.ServiceType == typeof(IConfiguration)));
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddInMemoryCollection(new Dictionary<string, string?>
{
AllowedToDomains = new List<string> { "example.tld" },
SmtpTlsEnabled = "false",
});
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.Build();
services.AddTransient<IMessageStore, DatabaseMessageStore>();
services.AddSingleton<SmtpServer>(
provider =>
{
var options = new SmtpServerOptionsBuilder()
.ServerName("aliasvault");
services.AddSingleton<IConfiguration>(configuration);
// Note: port 25 doesn't work in GitHub actions so we use these instead for the integration tests:
// - 2525 for the SMTP server
// - 5870 for the submission server
options.Endpoint(serverBuilder =>
serverBuilder
.Port(2525, false))
.Endpoint(serverBuilder =>
serverBuilder
.Port(5870, false));
return new SmtpServer(options.Build(), provider.GetRequiredService<IServiceProvider>());
});
services.AddAliasVaultDatabaseConfiguration(configuration);
services.AddHostedService<SmtpServerWorker>();
// Ensure the in-memory database is populated with tables
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
_dbContextFactory = scope.ServiceProvider.GetRequiredService<IAliasServerDbContextFactory>();
var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Database.Migrate();
}
});
ConfigureSmtpServices(services);
});
return builder.Build();
}
/// <summary>
/// Disposes of the test host and cleans up the temporary database.
/// Builds the SmtpService test host with a new database connection.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
/// <returns>IHost.</returns>
public IHost Build()
{
if (_dbContext != null)
// Get base builder with database connection already configured.
var builder = CreateBuilder();
// Add specific services for the TestExceptionWorker.
builder.ConfigureServices((context, services) =>
{
await _dbContext.DisposeAsync();
_dbContext = null;
}
ConfigureSmtpServices(services);
});
if (!string.IsNullOrEmpty(_tempDbName))
return builder.Build();
}
/// <summary>
/// Configures the SMTP services for the test host.
/// </summary>
/// <param name="services">The service collection to configure.</param>
private static void ConfigureSmtpServices(IServiceCollection services)
{
services.AddSingleton(new Config
{
// Create a connection to 'postgres' database to drop the test database
using var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password");
await conn.OpenAsync();
AllowedToDomains = new List<string> { "example.tld" },
SmtpTlsEnabled = "false",
});
// First terminate existing connections
using (var cmd = conn.CreateCommand())
services.AddTransient<IMessageStore, DatabaseMessageStore>();
services.AddSingleton<SmtpServer>(
provider =>
{
cmd.CommandText = $"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{_tempDbName}';
""";
await cmd.ExecuteNonQueryAsync();
}
var options = new SmtpServerOptionsBuilder()
.ServerName("aliasvault");
// Then drop the database
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
DROP DATABASE IF EXISTS "{_tempDbName}";
""";
await cmd.ExecuteNonQueryAsync();
}
// Note: port 25 doesn't work in GitHub actions so we use these instead for the integration tests:
// - 2525 for the SMTP server
// - 5870 for the submission server
options.Endpoint(serverBuilder =>
serverBuilder
.Port(2525, false))
.Endpoint(serverBuilder =>
serverBuilder
.Port(5870, false));
GC.SuppressFinalize(this);
}
return new SmtpServer(options.Build(), provider.GetRequiredService<IServiceProvider>());
});
services.AddHostedService<SmtpServerWorker>();
}
}

View File

@@ -0,0 +1,73 @@
//-----------------------------------------------------------------------
// <copyright file="StatusHostedServiceTests.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.IntegrationTests.StatusHostedService;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
/// <summary>
/// Integration tests for StatusHostedService wrapper.
/// </summary>
[TestFixture]
public class StatusHostedServiceTests
{
/// <summary>
/// The test host instance.
/// </summary>
private IHost _testHost;
/// <summary>
/// The test host builder instance.
/// </summary>
private TestHostBuilder _testHostBuilder;
/// <summary>
/// Setup logic for every test.
/// </summary>
[SetUp]
public void Setup()
{
_testHostBuilder = new TestHostBuilder();
_testHost = _testHostBuilder.Build();
}
/// <summary>
/// Tear down logic for every test.
/// </summary>
/// <returns>Task.</returns>
[TearDown]
public async Task TearDown()
{
await _testHost.StopAsync();
_testHost.Dispose();
await _testHostBuilder.DisposeAsync();
}
/// <summary>
/// Tests that the StatusHostedService properly logs errors from the wrapped service.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task LogsExceptionFromWrappedService()
{
// Start the service which will trigger the TestExceptionWorker to throw an exception.
await _testHost.StartAsync();
// Give it a moment to process.
await Task.Delay(3000);
// Check the logs for the expected error.
await using var dbContext = _testHostBuilder.GetDbContext();
var errorLog = await dbContext.Logs
.OrderByDescending(l => l.TimeStamp)
.FirstOrDefaultAsync(l => l.Level == "Error" && l.Exception.Contains("Test exception"));
Assert.That(errorLog, Is.Not.Null, "Expected error log from TestExceptionWorker was not found");
Assert.That(errorLog.Message, Does.Contain("An error occurred in StatusHostedService"), "Error log does not contain expected message from StatusHostedService");
}
}

View File

@@ -0,0 +1,23 @@
//-----------------------------------------------------------------------
// <copyright file="TestExceptionWorker.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.IntegrationTests.StatusHostedService;
using Microsoft.Extensions.Hosting;
/// <summary>
/// A simple worker that throws an exception during task execution. This is used for testing purposes.
/// </summary>
public class TestExceptionWorker() : BackgroundService
{
/// <inheritdoc/>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), stoppingToken);
throw new Exception("Test exception");
}
}

View File

@@ -0,0 +1,42 @@
// -----------------------------------------------------------------------
// <copyright file="TestHostBuilder.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
// -----------------------------------------------------------------------
namespace AliasVault.IntegrationTests.StatusHostedService;
using System.Reflection;
using AliasServerDb;
using AliasVault.WorkerStatus.ServiceExtensions;
using Microsoft.Extensions.Hosting;
/// <summary>
/// Builder class for creating a test host for the StatusHostedService wrapper in order to run integration tests
/// against it. This primarily tests basic functionality of the hosted service such as starting, stopping and error
/// handling.
///
/// The StatusHostedService is a wrapper around the HostedService class that provides additional functionality for
/// managing the status of the hosted service. This includes being able to start and stop the services from the
/// AliasVault admin panel.
/// </summary>
public class TestHostBuilder : AbstractTestHostBuilder
{
/// <summary>
/// Builds the test host for the TestExceptionWorker.
/// </summary>
/// <returns>IHost.</returns>
public IHost Build()
{
// Get base builder with database connection already configured.
var builder = CreateBuilder();
// Add specific services for the TestExceptionWorker.
builder.ConfigureServices((context, services) =>
{
services.AddStatusHostedService<TestExceptionWorker, AliasServerDbContext>(Assembly.GetExecutingAssembly().GetName().Name!);
});
return builder.Build();
}
}

View File

@@ -77,7 +77,7 @@ public class TaskRunnerTests
// Assert
await using var dbContext = await _testHostBuilder.GetDbContextAsync();
var generalLogs = await dbContext.Logs.ToListAsync();
var generalLogs = await dbContext.Logs.Where(x => x.Application == "TestApp").ToListAsync();
Assert.That(generalLogs, Has.Count.EqualTo(50), "Only recent general logs should remain");
}

View File

@@ -7,149 +7,41 @@
namespace AliasVault.IntegrationTests.TaskRunner;
using AliasServerDb;
using AliasServerDb.Configuration;
using AliasVault.Shared.Server.Services;
using AliasVault.TaskRunner.Tasks;
using AliasVault.TaskRunner.Workers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
/// <summary>
/// Builder class for creating a test host for the TaskRunner in order to run integration tests against it.
/// </summary>
public class TestHostBuilder : IAsyncDisposable
public class TestHostBuilder : AbstractTestHostBuilder
{
/// <summary>
/// The DbContextFactory instance that is created for the test.
/// </summary>
private IAliasServerDbContextFactory _dbContextFactory = null!;
/// <summary>
/// The cached DbContext instance that can be used during the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// The temporary database name for the test.
/// </summary>
private string? _tempDbName;
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public async Task<AliasServerDbContext> GetDbContextAsync()
{
return await _dbContextFactory.CreateDbContextAsync();
}
/// <summary>
/// Builds the TaskRunner test host.
/// </summary>
/// <returns>IHost.</returns>
public IHost Build()
{
// Create a temporary database for the test
_tempDbName = $"aliasdb_test_{Guid.NewGuid()}";
// Get base builder with database connection already configured.
var builder = CreateBuilder();
// Create a connection to 'postgres' database to create the test database
using (var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password"))
// Add specific services for the TestExceptionWorker.
builder.ConfigureServices((context, services) =>
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
CREATE DATABASE "{_tempDbName}";
""";
cmd.ExecuteNonQuery();
}
}
// Add server settings service
services.AddSingleton<ServerSettingsService>();
// Create the connection to the new test database
var dbConnection = new NpgsqlConnection($"Host=localhost;Port=5433;Database={_tempDbName};Username=aliasvault;Password=password");
var builder = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Override configuration
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddInMemoryCollection(new Dictionary<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
// Add maintenance tasks
services.AddTransient<IMaintenanceTask, LogCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailQuotaCleanupTask>();
// Add server settings service
services.AddSingleton<ServerSettingsService>();
// Add maintenance tasks
services.AddTransient<IMaintenanceTask, LogCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailQuotaCleanupTask>();
services.AddAliasVaultDatabaseConfiguration(configuration);
// Add the TaskRunner worker
services.AddHostedService<TaskRunnerWorker>();
// Ensure the database is populated with tables
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
_dbContextFactory = scope.ServiceProvider.GetRequiredService<IAliasServerDbContextFactory>();
var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Database.Migrate();
}
});
// Add the TaskRunner worker
services.AddHostedService<TaskRunnerWorker>();
});
return builder.Build();
}
/// <summary>
/// Disposes of the test host and cleans up the temporary database.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_dbContext != null)
{
await _dbContext.DisposeAsync();
_dbContext = null;
}
if (!string.IsNullOrEmpty(_tempDbName))
{
// Create a connection to 'postgres' database to drop the test database
using var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password");
await conn.OpenAsync();
// First terminate existing connections
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{_tempDbName}';
""";
await cmd.ExecuteNonQueryAsync();
}
// Then drop the database
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
DROP DATABASE IF EXISTS "{_tempDbName}";
""";
await cmd.ExecuteNonQueryAsync();
}
}
GC.SuppressFinalize(this);
}
}

View File

@@ -147,7 +147,7 @@ public static partial class Program
await MigrateTable(sqliteContext.AliasVaultRoles, pgContext.AliasVaultRoles, pgContext, "AliasVaultRoles");
await MigrateTable(sqliteContext.AliasVaultUsers, pgContext.AliasVaultUsers, pgContext, "AliasVaultUsers");
await MigrateTable(sqliteContext.ServerSettings, pgContext.ServerSettings, pgContext, "ServerSettings");
await MigrateTable(sqliteContext.TaskRunnerJobs, pgContext.TaskRunnerJobs, pgContext, "TaskRunnerJobs");
await MigrateTable(sqliteContext.TaskRunnerJobs, pgContext.TaskRunnerJobs, pgContext, "TaskRunnerJobs", true);
await MigrateTable(sqliteContext.DataProtectionKeys, pgContext.DataProtectionKeys, pgContext, "DataProtectionKeys", true);
await MigrateTable(sqliteContext.Logs, pgContext.Logs, pgContext, "Logs", true);
await MigrateTable(sqliteContext.AuthLogs, pgContext.AuthLogs, pgContext, "AuthLogs", true);

View File

@@ -27,7 +27,7 @@ public class StatusHostedService<T>(ILogger<StatusHostedService<T>> logger, Glob
/// <summary>
/// Maximum delay before restarting the worker.
/// </summary>
private const int _restartMaxDelayInMs = 300000;
private const int _restartMaxDelayInMs = 3600000;
/// <summary>
/// Lock object to prevent multiple tasks from starting the worker at the same time.
@@ -53,31 +53,70 @@ public class StatusHostedService<T>(ILogger<StatusHostedService<T>> logger, Glob
while (!stoppingToken.IsCancellationRequested)
{
// Add a second cancellationToken linked to the parent cancellation token.
// When the parent gets canceled this gets canceled as well. However, this one can also
// be canceled with a signal from the StatusWorker.
var workerCancellationTokenSource =
CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
// Start the inner while loop with the second cancellationToken.
await ExecuteInnerAsync(workerCancellationTokenSource);
if (!stoppingToken.IsCancellationRequested)
try
{
// If the parent service was not stopped, wait for a second before attempting to restart the worker.
await Task.Delay(1000, stoppingToken);
// Start the inner while loop with the second cancellationToken.
await ExecuteInnerAsync(stoppingToken);
}
catch (OperationCanceledException ex)
{
// Expected so we only log information.
logger.LogInformation(ex, "StatusHostedService<{ServiceType}> is stopping due to a cancellation request.", typeof(T).Name);
break;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred in StatusHostedService<{ServiceType}>", typeof(T).Name);
}
finally
{
if (!stoppingToken.IsCancellationRequested)
{
// If the parent service was not stopped, wait for a second before attempting to restart the worker.
await Task.Delay(1000, stoppingToken);
}
}
}
}
/// <summary>
/// Calls the ExecuteAsync method of the inner service.
/// </summary>
/// <param name="innerService">The inner service.</param>
/// <param name="cancellationToken">Cancellation token.</param>
private static async Task CallExecuteAsync(T innerService, CancellationToken cancellationToken)
{
if (innerService is BackgroundService backgroundService)
{
var executeMethod = backgroundService.GetType().GetMethod("ExecuteAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var executionTask = (Task)executeMethod!.Invoke(backgroundService, new object[] { cancellationToken })!;
// Wait for the ExecuteAsync method to complete or throw.
await executionTask;
}
else
{
// For non-BackgroundService implementations, start the service as normal and wait indefinitely
await innerService.StartAsync(cancellationToken);
// For non-BackgroundService implementations, just wait indefinitely
await Task.Delay(Timeout.Infinite, cancellationToken);
}
}
/// <summary>
/// Start the inner while loop which adds a second cancellationToken that is controlled by the StatusWorker.
/// </summary>
/// <param name="workerCancellationTokenSource">Cancellation token.</param>
private async Task ExecuteInnerAsync(CancellationTokenSource workerCancellationTokenSource)
/// <param name="cancellationToken">Cancellation token.</param>
private async Task ExecuteInnerAsync(CancellationToken cancellationToken)
{
Task? workerTask = null;
// Add a second cancellationToken linked to the parent cancellation token.
// When the parent gets canceled this gets canceled as well. However, this one can also
// be canceled with a signal from the StatusWorker.
using var workerCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
while (!workerCancellationTokenSource.IsCancellationRequested)
{
if (globalServiceStatus.CurrentStatus.ToStatusEnum() == Status.Started || globalServiceStatus.CurrentStatus.ToStatusEnum() == Status.Starting)
@@ -86,7 +125,6 @@ public class StatusHostedService<T>(ILogger<StatusHostedService<T>> logger, Glob
{
if (workerTask == null)
{
globalServiceStatus.SetWorkerStatus(typeof(T).Name, true);
workerTask = Task.Run(() => WorkerLogic(workerCancellationTokenSource.Token), workerCancellationTokenSource.Token);
}
}
@@ -100,11 +138,15 @@ public class StatusHostedService<T>(ILogger<StatusHostedService<T>> logger, Glob
else if (globalServiceStatus.CurrentStatus.ToStatusEnum() == Status.Stopped)
{
// Do nothing, the worker is stopped.
globalServiceStatus.SetWorkerStatus(typeof(T).Name, false);
}
// Wait for a second before checking the status again.
await Task.Delay(1000);
await Task.Delay(1000, cancellationToken);
}
// If we get here, cancel the worker task if it is still running.
await workerCancellationTokenSource.CancelAsync();
}
/// <summary>
@@ -120,43 +162,53 @@ public class StatusHostedService<T>(ILogger<StatusHostedService<T>> logger, Glob
{
globalServiceStatus.SetWorkerStatus(typeof(T).Name, true);
await innerService.StartAsync(cancellationToken);
await Task.Delay(Timeout.Infinite, cancellationToken);
// If the inner service is a BackgroundService, listen for the results via reflection.
await CallExecuteAsync(innerService, cancellationToken);
}
catch (OperationCanceledException ex)
{
// Expected so we only log information.
logger.LogInformation(ex, "StatusHostedService<{ServiceType}> is stopping due to a cancellation request.", typeof(T).Name);
break;
}
catch (Exception ex)
{
logger.LogError(ex, "An error occurred in StatusHostedService<{ServiceType}>", typeof(T).Name);
// If service is explicitly stopped, break out of the loop immediately.
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
finally
{
logger.LogWarning("StatusHostedService<{ServiceType}> stopped at: {Time}", typeof(T).Name, DateTimeOffset.Now);
globalServiceStatus.SetWorkerStatus(typeof(T).Name, false);
// Reset the delay when the service is explicitly stopped
if (cancellationToken.IsCancellationRequested)
{
_restartDelayInMs = _restartMinDelayInMs;
}
}
// If a fault occurred in the innerService but it was not canceled,
// wait for a second before attempting to auto-restart the worker.
while (!cancellationToken.IsCancellationRequested)
if (cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(_restartDelayInMs, cancellationToken);
break; // Exit the loop if delay is successful
}
catch (TaskCanceledException)
{
// If the delay is canceled, exit the loop
break;
}
return;
}
// Exponential backoff with a maximum delay
_restartDelayInMs = Math.Min(_restartDelayInMs * 2, _restartMaxDelayInMs);
try
{
// If an exception occurred, delay with exponential backoff with a maximum before retrying.
await Task.Delay(_restartDelayInMs, cancellationToken);
_restartDelayInMs = Math.Min(_restartDelayInMs * 2, _restartMaxDelayInMs);
}
catch (OperationCanceledException)
{
// Reset delay on cancellation
_restartDelayInMs = _restartMinDelayInMs;
return;
}
}
}
}

View File

@@ -13,7 +13,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
/// <summary>
/// StatusWorker class for monitoring and controlling the status of the worker services.
/// StatusWorker class for monitoring and controlling the status of individual worker services through a database.
/// </summary>
public class StatusWorker(ILogger<StatusWorker> logger, Func<IWorkerStatusDbContext> createDbContext, GlobalServiceStatus globalServiceStatus) : BackgroundService
{
@@ -33,27 +33,18 @@ public class StatusWorker(ILogger<StatusWorker> logger, Func<IWorkerStatusDbCont
try
{
var statusEntry = await GetServiceStatus();
switch (statusEntry.CurrentStatus.ToStatusEnum())
{
case Status.Started:
// Ensure that all workers are running, if not, revert to "Starting" CurrentStatus.
if (!globalServiceStatus.AreAllWorkersRunning())
{
await SetServiceStatus(statusEntry, Status.Starting.ToString());
logger.LogInformation(
"Status was set to Started but not all workers are running (yet). Reverting to Starting.");
}
await HandleStartedStatus(statusEntry);
break;
case Status.Starting:
await WaitForAllWorkersToStart(stoppingToken);
await SetServiceStatus(statusEntry, Status.Started.ToString());
logger.LogInformation("All workers started.");
await HandleStartingStatus(statusEntry);
break;
case Status.Stopping:
await WaitForAllWorkersToStop(stoppingToken);
await SetServiceStatus(statusEntry, Status.Stopped.ToString());
logger.LogInformation("All workers stopped.");
await HandleStoppingStatus(statusEntry);
break;
case Status.Stopped:
logger.LogInformation("Service is (soft) stopped.");
@@ -78,6 +69,56 @@ public class StatusWorker(ILogger<StatusWorker> logger, Func<IWorkerStatusDbCont
await SetServiceStatus(await GetServiceStatus(), "Stopped");
}
/// <summary>
/// Handles the Started status.
/// </summary>
/// <param name="statusEntry">The WorkerServiceStatus entry.</param>
/// <returns>Task.</returns>
private async Task HandleStartedStatus(WorkerServiceStatus statusEntry)
{
if (!globalServiceStatus.AreAllWorkersRunning())
{
await SetServiceStatus(statusEntry, Status.Starting.ToString());
logger.LogInformation("Status was set to Started but not all workers are running (yet). Reverting to Starting.");
}
}
/// <summary>
/// Handles the Starting status.
/// </summary>
/// <param name="statusEntry">The WorkerServiceStatus entry.</param>
/// <returns>Task.</returns>
private async Task HandleStartingStatus(WorkerServiceStatus statusEntry)
{
if (globalServiceStatus.AreAllWorkersRunning())
{
await SetServiceStatus(statusEntry, Status.Started.ToString());
logger.LogInformation("All workers started.");
}
else
{
logger.LogInformation("Waiting for all workers to start.");
}
}
/// <summary>
/// Handles the Stopping status.
/// </summary>
/// <param name="statusEntry">The WorkerServiceStatus entry.</param>
/// <returns>Task.</returns>
private async Task HandleStoppingStatus(WorkerServiceStatus statusEntry)
{
if (globalServiceStatus.AreAllWorkersStopped())
{
await SetServiceStatus(statusEntry, Status.Stopped.ToString());
logger.LogInformation("All workers stopped.");
}
else
{
logger.LogInformation("Waiting for all workers to stop.");
}
}
/// <summary>
/// Gets the current status record of the service from database.
/// </summary>
@@ -126,32 +167,6 @@ public class StatusWorker(ILogger<StatusWorker> logger, Func<IWorkerStatusDbCont
await _dbContext.SaveChangesAsync();
}
/// <summary>
/// Waits for all workers to start.
/// </summary>
/// <param name="stoppingToken">CancellationToken.</param>
private async Task WaitForAllWorkersToStart(CancellationToken stoppingToken)
{
while (!globalServiceStatus.AreAllWorkersRunning())
{
logger.LogInformation("Waiting for all workers to start...");
await Task.Delay(1000, stoppingToken);
}
}
/// <summary>
/// Waits for all workers to stop.
/// </summary>
/// <param name="stoppingToken">CancellationToken.</param>
private async Task WaitForAllWorkersToStop(CancellationToken stoppingToken)
{
while (!globalServiceStatus.AreAllWorkersStopped())
{
logger.LogInformation("Waiting for all workers to stop...");
await Task.Delay(1000, stoppingToken);
}
}
/// <summary>
/// Retrieves status record or creates an initial status record if it does not exist.
/// </summary>