mirror of
https://github.com/aliasvault/aliasvault.git
synced 2025-12-24 06:39:12 -05:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aca607e579 | ||
|
|
ed053422ba | ||
|
|
955b8638ce | ||
|
|
1d8883cc94 | ||
|
|
48281f92e6 | ||
|
|
f19db2c010 | ||
|
|
f0d397c8af | ||
|
|
fafa51d787 | ||
|
|
202151e4f1 | ||
|
|
c123edccd4 | ||
|
|
50cab3a2f3 | ||
|
|
0184e32e6d | ||
|
|
d73d4e90e0 | ||
|
|
06d38842f5 | ||
|
|
b0748316ff | ||
|
|
8f8b4af3c9 | ||
|
|
11bf183cbb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -415,3 +415,6 @@ docs/.bundle
|
||||
# Database files
|
||||
database/postgres
|
||||
database/postgres-dev
|
||||
|
||||
# Temp files
|
||||
temp
|
||||
|
||||
150
install.sh
150
install.sh
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
164
src/Tests/AliasVault.IntegrationTests/AbstractTestHostBuilder.cs
Normal file
164
src/Tests/AliasVault.IntegrationTests/AbstractTestHostBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user