// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.CommandLine; using System.CommandLine.NamingConventionBinder; using Duplicati.Library.AutoUpdater; using Duplicati.Library.Interface; using Duplicati.Library.Main.Database; using Duplicati.Library.SQLiteHelper; using Duplicati.Library.Utility; namespace Duplicati.CommandLine.DatabaseTool.Commands; /// /// The downgrade command /// public static class Upgrade { /// /// Creates the downgrade command /// /// The downgrade command public static Command Create() => new Command("upgrade", "Upgrades one or more databases to a larger version") { new Argument("databases", "The databases to upgrade") { Arity = ArgumentArity.ZeroOrMore }, new Option("--server-datafolder", description: "The folder with databases", getDefaultValue: () => new DirectoryInfo(DataFolderLocator.GetDefaultStorageFolder(DataFolderManager.SERVER_DATABASE_FILENAME, false, true))), new Option("--server-version", description: "The version to upgrade the server database to; zero or less means latest", getDefaultValue: () => 0), new Option("--local-version", description: "The version to upgrade local databases to; zero or less means latest", getDefaultValue: () => 0), new Option("--no-backups", description: "Do not create backups before upgrade", getDefaultValue: () => false), new Option("--include-untracked-databases", description: "Include untracked databases in the upgrade process", getDefaultValue: () => false) } .WithHandler(CommandHandler.Create((databases, serverdatafolder, serverversion, localversion, nobackups, includeuntrackeddatabases) => { databases = Helper.FindAllDatabases(databases, serverdatafolder.FullName, includeuntrackeddatabases).Await(); if (databases.Length == 0) { Console.WriteLine("No databases found to upgrade"); return; } var serverVersions = ExtractScriptsFromAssembly(typeof(Library.RestAPI.Database.DatabaseSchemaMarker)); var localVersions = ExtractScriptsFromAssembly(typeof(Library.Main.Database.DatabaseSchemaMarker)); if (serverversion <= 0) serverversion = serverVersions.Max(x => x.Version); if (localversion <= 0) localversion = localVersions.Max(x => x.Version); if (serverversion > serverVersions.Max(x => x.Version)) throw new UserInformationException($"Server version {serverversion} is greater than the latest version {serverVersions.Max(x => x.Version)}", "UnsupportedUpgradeVersion"); if (localversion > localVersions.Max(x => x.Version)) throw new UserInformationException($"Local version {localversion} is greater than the latest version {localVersions.Max(x => x.Version)}", "UnsupportedUpgradeVersion"); foreach (var db in databases) { Console.WriteLine($"Examining {db} ..."); if (!File.Exists(db)) { Console.WriteLine($"Database {db} does not exist"); continue; } int version; bool isserverdb; try { (version, isserverdb) = Helper.ExamineDatabase(db).Await(); } catch (Exception ex) { Console.WriteLine($"Error reading database {db}: {ex.Message}"); continue; } Console.WriteLine($"Database {db} is version {version} and is a {(isserverdb ? "server" : "local")} database"); ApplyUpgrade(db, version, isserverdb ? serverversion : localversion, isserverdb ? serverVersions : localVersions, nobackups) .Await(); } })); /// /// Finds all upgrade scripts in the assembly /// /// The type to use as a marker for the assembly /// The list of upgrade scripts private static IEnumerable ExtractScriptsFromAssembly(Type markerType) { string ReadStream(string path) { using var reader = new StreamReader(markerType.Assembly.GetManifestResourceStream(path) ?? throw new InvalidOperationException()); return reader.ReadToEnd(); } static string? ResourceNameToPath(string nsprefix, string resourcename) { if (resourcename.StartsWith(nsprefix)) return resourcename.Substring(nsprefix.Length); return null; } return markerType.Assembly.GetManifestResourceNames() .Select(x => (ResourceName: x, Filename: ResourceNameToPath(markerType.Namespace + ".Database_schema.", x))) .Where(x => x.Filename != null && x.Filename.EndsWith(".sql")) .Where(x => x.Filename != "Schema.sql") .Select(x => new UpgradeScript(Version: int.Parse(x.Filename!.Split('.').First()), Filename: x.Filename!, Content: ReadStream(x.ResourceName))) .ToList(); } /// /// The upgrade script /// /// The path to the assembly resource /// The script version /// The script contents private sealed record UpgradeScript(string Filename, int Version, string Content); /// /// Applies the upgrade scripts to the database /// /// The database path /// The current database version /// The target database version /// The upgrade scripts /// Flag to disable backups /// A task that completes when the upgrade is done. private static async Task ApplyUpgrade(string db, int dbversion, int targetversion, IEnumerable scripts, bool nobackups) { if (targetversion > dbversion) { var requiredVersions = Enumerable.Range(dbversion + 1, targetversion - dbversion) .ToList(); var missingScripts = requiredVersions .Where(x => !scripts.Any(y => y.Version == x)) .ToList(); if (missingScripts.Count > 0) { Console.WriteLine($"Missing upgrade scripts for versions: {string.Join(", ", missingScripts)}"); return; } var upgradeScripts = requiredVersions .Select(x => scripts.First(y => y.Version == x)) .ToList(); Console.WriteLine($"Upgrading {db} from version {dbversion} to {targetversion}"); if (!nobackups) Helper.CreateFileBackup(db); await using var con = await SQLiteLoader.LoadConnectionAsync(db) .ConfigureAwait(false); await using var tr = con.BeginTransaction(); await using var cmd = con.CreateCommand(tr); foreach (var script in upgradeScripts) { Console.WriteLine($"Applying upgrade script {script.Filename} ..."); try { cmd.SetCommandAndParameters(script.Content); var r = await cmd.ExecuteScalarAsync() .ConfigureAwait(false); if (r != null) throw new UserInformationException($"{r}", "UpgradeScriptFailure"); await cmd.SetCommandAndParameters(@" UPDATE ""Version"" SET ""Version"" = @Version ") .SetParameterValue("@Version", script.Version) .ExecuteNonQueryAsync() .ConfigureAwait(false); } catch (Exception ex) { throw new UserInformationException($"Failed applying upgrade script {script.Filename}: {ex.Message}", "DowngradeScriptFailure", ex); } } tr.Commit(); Console.WriteLine($"Upgraded {db} to version {targetversion}"); } else if (dbversion == targetversion) { Console.WriteLine($"Database {db} is already at version {targetversion}"); } else { Console.WriteLine($"Database {db} is already at version {dbversion} (cannot upgrade to {targetversion})"); } } }