feat: add support for applying migrations automatically
All checks were successful
Update changelog / changelog (push) Successful in 25s

This commit is contained in:
2025-12-17 01:07:05 +01:00
parent f5df949a4c
commit ac2b6aba6e
4 changed files with 102 additions and 12 deletions

View File

@@ -1,7 +1,5 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Shadow.Entities; using Shadow.Entities;
using System.Diagnostics.Metrics;
using System.Reflection.Emit;
namespace Shadow.Data; namespace Shadow.Data;
public class ApplicationDbContext : DbContext public class ApplicationDbContext : DbContext

View File

@@ -78,7 +78,7 @@ if (args.FirstOrDefault() is not null)
ApplicationDbContext db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); ApplicationDbContext db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
GeneralUseHelpers guhf = scope.ServiceProvider.GetRequiredService<GeneralUseHelpers>(); GeneralUseHelpers guhf = scope.ServiceProvider.GetRequiredService<GeneralUseHelpers>();
Cli cli = new(db, guhf, args); Cli cli = new(db, guhf, args);
shutdown = await cli.Parse(); shutdown = cli.Parse();
} }
if (shutdown) return; if (shutdown) return;
} }

View File

@@ -17,7 +17,7 @@ public class Cli
args = _args; args = _args;
} }
public async Task<bool> Parse() public bool Parse()
{ {
// Returns true if the program can finish execution. // Returns true if the program can finish execution.
bool exit = true; bool exit = true;
@@ -30,16 +30,19 @@ public class Cli
case "adduser": case "adduser":
case "--adduser": case "--adduser":
case "--add-user": case "--add-user":
if (!Seeder.EnsureMigrations(db)) return true;
AddUser(); AddUser();
break; break;
case "resetpassword": case "resetpassword":
case "--resetpassword": case "--resetpassword":
case "--reset-password": case "--reset-password":
if (!Seeder.EnsureMigrations(db)) return true;
ResetPassword(); ResetPassword();
break; break;
case "removeuser": case "removeuser":
case "--removeuser": case "--removeuser":
case "--remove-user": case "--remove-user":
if (!Seeder.EnsureMigrations(db)) return true;
RemoveUser(); RemoveUser();
break; break;
case "help": case "help":
@@ -49,6 +52,7 @@ public class Cli
case "/h": case "/h":
case "/?": case "/?":
case "?": case "?":
// Help should be shown without forcing migrations.
ShowHelp(); ShowHelp();
break; break;
case "setupwizard": case "setupwizard":
@@ -58,6 +62,7 @@ public class Cli
case "--setup": case "--setup":
case "configure": case "configure":
case "--configure": case "--configure":
if (!Seeder.EnsureMigrations(db)) return true;
SetupWizard(); SetupWizard();
break; break;
default: default:
@@ -73,13 +78,21 @@ public class Cli
// Shown when "Shadow --help"/"Shadow help"/... is ran. // Shown when "Shadow --help"/"Shadow help"/... is ran.
Console.WriteLine( Console.WriteLine(
$"--- Shadow commandline utility ---\n" + $"--- Shadow commandline utility ---\n" +
$"Shadow version: #{ThisAssembly.Git.Commit} {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n\n" + $"Shadow version: #{ThisAssembly.Git.Commit} {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n" +
$"\n" +
$"Available commands:\n" + $"Available commands:\n" +
$"\n" +
$"=== User management ===\n" +
$"- addUser [username] - create a new user,\n" + $"- addUser [username] - create a new user,\n" +
$"- resetPassword [username] - reset a user's password,\n" + $"- resetPassword [username] - reset a user's password,\n" +
$"- removeUser [username] - remove the user COMPLETELY.\n\n" + $"- removeUser [username] - remove the user COMPLETELY.\n" +
$"\n" +
$"=== Server maintenance ===\n" +
$"- setupWizard - configure library and thumbnail location on disk.\n" +
$"\n" +
$"Username is optional. If not provided, user will be prompted to enter it.\n" + $"Username is optional. If not provided, user will be prompted to enter it.\n" +
$"Running without specifying a command launches a Kestrel web server.\n\n" + $"Running without specifying a command launches a Kestrel web server.\n" +
$"\n" +
$"License: AGPLv3+, Source code: https://gitea.7o7.cx/sherl/Shadow" $"License: AGPLv3+, Source code: https://gitea.7o7.cx/sherl/Shadow"
); );
} }
@@ -271,7 +284,8 @@ public class Cli
Global? musicLibraryPath = db.Globals.FirstOrDefault(g => g.Key == "musicLibraryPath"); Global? musicLibraryPath = db.Globals.FirstOrDefault(g => g.Key == "musicLibraryPath");
Global? musicThumbnailPath = db.Globals.FirstOrDefault(g => g.Key == "musicThumbnailPath"); Global? musicThumbnailPath = db.Globals.FirstOrDefault(g => g.Key == "musicThumbnailPath");
if (musicLibraryPath is not null || musicThumbnailPath is not null) bool configurationExists = musicLibraryPath is not null || musicThumbnailPath is not null;
if (configurationExists)
{ {
Console.WriteLine(" Found existing configuration:"); Console.WriteLine(" Found existing configuration:");
if (musicLibraryPath is not null) if (musicLibraryPath is not null)
@@ -314,12 +328,17 @@ public class Cli
Console.WriteLine("success!"); Console.WriteLine("success!");
success = true; success = true;
Console.WriteLine("\n" +
"Tip: On your next run of Shadow, a web server will start.\n" +
"If you ever need to enter setup wizard again, use `Shadow setupWizard`.\n" +
"For a complete list of available commands use `Shadow help`.\n");
} }
return success; return success;
} }
public string ReadPassword(string prompt = " Enter password (will not be echoed back): ") public static string ReadPassword(string prompt = " Enter password (will not be echoed back): ")
{ {
// https://www.silicloud.com/blog/how-to-hide-content-in-the-console-using-c/ // https://www.silicloud.com/blog/how-to-hide-content-in-the-console-using-c/
string password = String.Empty; string password = String.Empty;
@@ -344,14 +363,14 @@ public class Cli
return password; return password;
} }
public string? ReadName(string prompt = " Enter username: ") public static string? ReadName(string prompt = " Enter username: ")
{ {
Console.Write(prompt); Console.Write(prompt);
string? input = Console.ReadLine(); string? input = Console.ReadLine();
return input; return input;
} }
public bool YesNoPrompt(string prompt, bool? default_value = null) public static bool YesNoPrompt(string prompt, bool? default_value = null)
{ {
// Checks if user input starts with "y", and if it does, returns true. // Checks if user input starts with "y", and if it does, returns true.
// Otherwise checks for "n". If both checks fail, and default_value is null, // Otherwise checks for "n". If both checks fail, and default_value is null,
@@ -388,7 +407,7 @@ public class Cli
return response; return response;
} }
public string DefaultPrompt(string prompt, string? default_value = null) public static string DefaultPrompt(string prompt, string? default_value = null)
{ {
// Prompt the user repeatedly for answer. If default value // Prompt the user repeatedly for answer. If default value
// is specified, an enter will be treated as if the user // is specified, an enter will be treated as if the user

View File

@@ -21,6 +21,10 @@ public class Seeder
Console.WriteLine($"You're running Shadow, commit {ThisAssembly.Git.Commit} of branch {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n"); Console.WriteLine($"You're running Shadow, commit {ThisAssembly.Git.Commit} of branch {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n");
// Check migration status. If we can't proceed, exit now.
bool migrationSucceeded = EnsureMigrations(db);
if (!migrationSucceeded) return true;
// Check if this is a clean, first run. If so, run the setup wizard. // Check if this is a clean, first run. If so, run the setup wizard.
Global? lastVersion = db.Globals.FirstOrDefault(c => c.Key == "lastVersion"); Global? lastVersion = db.Globals.FirstOrDefault(c => c.Key == "lastVersion");
Global? runs = db.Globals.FirstOrDefault(g => g.Key == "runs"); Global? runs = db.Globals.FirstOrDefault(g => g.Key == "runs");
@@ -104,4 +108,73 @@ public class Seeder
return shutdown; return shutdown;
} }
public static bool EnsureMigrations(ApplicationDbContext dbContext)
{
// Returns true if migrations have been successfully applied.
// Otherwise returns false.
bool migrationSuccess = false;
List<string> availableMigrations = dbContext.Database.GetMigrations().ToList();
List<string> appliedMigrations = dbContext.Database.GetAppliedMigrations().ToList();
bool hasMissingMigrations = availableMigrations.Count > appliedMigrations.Count;
bool doMigrationsMatch = availableMigrations.SequenceEqual(appliedMigrations);
if (hasMissingMigrations)
{
bool userMigrationConsent = false;
Console.WriteLine("\n" +
"========================================\n" +
"[Warn] Database migrations missing!\n" +
"========================================");
if (appliedMigrations.Count == 0)
{
Console.WriteLine( "Empty database detected. Applying migrations is recommended, and required if this is the first run of Shadow.");
userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [Y/n]: ", true);
}
else
{
Console.WriteLine($"Detected existing {appliedMigrations.Count} migrations. Backing up the database before applying migrations is recommended.");
userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [y/N]: ", false);
}
// Do we have user permission to perform migration?
if (userMigrationConsent)
{
dbContext.Database.Migrate();
dbContext.SaveChanges();
}
else
{
Console.WriteLine("Exiting...");
return migrationSuccess;
}
// Check if migrations match afterwards
List<string> newlyAppliedMigrations = dbContext.Database.GetAppliedMigrations().ToList();
bool doNewlyAppliedMigrationsMatch = availableMigrations.SequenceEqual(newlyAppliedMigrations);
if (doNewlyAppliedMigrationsMatch)
{
Console.WriteLine("\n" +
"==============================\n" +
"Migration success!\n" +
"==============================\n");
migrationSuccess = true;
}
else
{
// An error ocurred when applying migrations automatically
Console.WriteLine("\n" +
"==================================================\n" +
"[Error] Failed applying migrations automatically!\n" +
"==================================================\n");
Console.WriteLine("The list of newly applied migrations does not match the list of planned migrations.\n" +
"Shadow cannot operate with missing migrations. Please manually revise the database structure and create backups before altering it.\n");
migrationSuccess = false;
}
}
else migrationSuccess = true;
return migrationSuccess;
}
} }