Stufe 1 (#764): Werkzeugeinstellungen, Sicherungszeitplan und Import als neue Instanz
- Werkzeugformulare aus den typisierten Einstellungsklassen (L8): Geheimnisse
nur schreibend, nur geaenderte Schluessel werden ersetzt, Rechte bleiben
- Database.allowedTables las ein JSON-Array als rohen Text und haette es beim
naechsten Speichern zerlegt
- Automatische Sicherung der Instanz in der Sicherungsansicht (L10)
- Sicherung hochladen und als neue Instanz einspielen (L11): Pfade der alten
Maschine werden zurueckgesetzt, eine Kopie neben dem Original bekommt eine
eigene ID, Autostart aus; Upload bis 4 GB
- api.js schickte eine Datei als "{}"; Dialoge schliessen beim Seitenwechsel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8552755bb6
commit
d2559c763a
@@ -121,6 +121,9 @@ public sealed class InstanceRegistry : IAsyncDisposable
|
||||
/// nach einem Neustart der Maschine auch das starten, was jemand mit Absicht
|
||||
/// angehalten hat.</para>
|
||||
/// </summary>
|
||||
/// <summary>#764: Where the instances live — for importing a backup as a new one.</summary>
|
||||
public InstanceDirectoryManager Directories => _directories;
|
||||
|
||||
public async Task RefreshAsync(CancellationToken ct = default)
|
||||
{
|
||||
// ListInstances liest den Namen aus InstanceSettings.json gleich mit - und tut
|
||||
|
||||
@@ -12,6 +12,10 @@ namespace ClawdDotNet.Manager;
|
||||
public sealed record BackupOutcome(
|
||||
string Path, string FileName, long SizeBytes, bool Uploaded, string? UploadProblem);
|
||||
|
||||
/// <summary>#764: What importing a backup as a new instance came to.</summary>
|
||||
/// <param name="KeyNeeded">The backup is from another installation; its license key is needed.</param>
|
||||
public sealed record ImportOutcome(bool Ok, string Message, bool KeyNeeded = false, string? Directory = null);
|
||||
|
||||
/// <summary>
|
||||
/// Die Sicherungen aller Instanzen, im Manager.
|
||||
///
|
||||
@@ -179,6 +183,134 @@ public sealed class ManagerBackups : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #764: Restores an uploaded backup as a <b>new</b> instance — the move to a fresh machine,
|
||||
/// which the desktop restore window could do and the WebUI could not: there, no instance and
|
||||
/// therefore no backup list exists yet.
|
||||
///
|
||||
/// <para><b>Never into an existing directory.</b> Restoring over an instance is the other
|
||||
/// endpoint, which stops it first. Here the target must not exist, and if anything fails it
|
||||
/// is removed again — it did not exist before, so nothing of value goes with it.</para>
|
||||
///
|
||||
/// <para><b>The restored instance file is from another place.</b> Its working directory, and
|
||||
/// absolute log and backup directories, point at the old machine — or, for a copy on this
|
||||
/// machine, into the original instance, which would then get the copy's logs and lose
|
||||
/// backups to its rotation. They are reset to the new directory. A copy whose id already
|
||||
/// exists here gets a new id and loses the reporting token, so the two do not report as one.
|
||||
/// Autostart is switched off: a just-imported instance starts when someone starts it.</para>
|
||||
/// </summary>
|
||||
public async Task<ImportOutcome> ImportAsNewInstanceAsync(
|
||||
string archivePath, string name, string? sourceLicenseKey, string? passphrase, CancellationToken ct = default)
|
||||
{
|
||||
name = name.Trim();
|
||||
if (name.Length == 0)
|
||||
return new ImportOutcome(false, "The new instance needs a name.");
|
||||
|
||||
var directories = _registry.Directories;
|
||||
var target = directories.GetInstancePath(name);
|
||||
if (Directory.Exists(target))
|
||||
return new ImportOutcome(false, $"An instance '{name}' already exists.");
|
||||
|
||||
string? key = null;
|
||||
if (LicenseBackupCipher.IsEncrypted(archivePath))
|
||||
{
|
||||
var given = !string.IsNullOrWhiteSpace(sourceLicenseKey);
|
||||
key = given ? sourceLicenseKey : LicenseKey;
|
||||
|
||||
if (key is null || !LicenseBackupCipher.MatchesKey(archivePath, key))
|
||||
{
|
||||
return new ImportOutcome(false, given
|
||||
? "The license key given does not match this backup."
|
||||
: "This backup comes from another installation. Its license key is needed to restore it.",
|
||||
KeyNeeded: true);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _service.RestoreAsync(archivePath, target, new RestoreOptions
|
||||
{
|
||||
Passphrase = string.IsNullOrEmpty(passphrase) ? null : passphrase,
|
||||
LicenseKey = key,
|
||||
Overwrite = false
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
var notes = AdoptImportedConfig(directories, target, name);
|
||||
await _registry.RefreshAsync(ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Backup imported as new instance {Name}: {Path}", name, target);
|
||||
|
||||
return new ImportOutcome(true,
|
||||
$"Imported as new instance '{name}'." + (notes.Count == 0 ? "" : " " + string.Join(" ", notes)),
|
||||
Directory: target);
|
||||
}
|
||||
catch (Exception ex) when (ex is BackupException or IOException or InvalidDataException
|
||||
or UnauthorizedAccessException or InvalidOperationException
|
||||
or JsonException or FileNotFoundException)
|
||||
{
|
||||
TryRemove(target);
|
||||
_logger.LogWarning(ex, "Import of a backup as instance {Name} failed", name);
|
||||
return new ImportOutcome(false, $"Import failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> AdoptImportedConfig(InstanceDirectoryManager directories, string target, string name)
|
||||
{
|
||||
var notes = new List<string>();
|
||||
var config = directories.LoadInstanceConfig(target);
|
||||
|
||||
config.InstanceName = name;
|
||||
config.WorkingDirectory = target;
|
||||
config.AutostartByManager = false;
|
||||
|
||||
if (Path.IsPathRooted(config.LogDirectory))
|
||||
{
|
||||
config.LogDirectory = "";
|
||||
notes.Add("The log directory pointed outside the instance and now uses the default.");
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(config.BackupDirectory))
|
||||
{
|
||||
config.BackupDirectory = "";
|
||||
notes.Add("The backup directory pointed outside the instance and now uses the default.");
|
||||
}
|
||||
|
||||
var taken = directories.ListInstances()
|
||||
.Where(i => !PathBoundary.Comparer.Equals(Path.GetFullPath(i.FolderPath), Path.GetFullPath(target)))
|
||||
.Select(i => TryReadId(directories, i.FolderPath))
|
||||
.Where(id => id is not null)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.InstanceId) || taken.Contains(config.InstanceId))
|
||||
{
|
||||
config.InstanceId = Guid.NewGuid().ToString("N")[..8];
|
||||
config.Watchdog.AgentToken = "";
|
||||
notes.Add("An instance with the same id exists here, so the copy got a new id and will get its own reporting token.");
|
||||
}
|
||||
|
||||
directories.SaveInstanceConfig(target, config);
|
||||
return notes;
|
||||
}
|
||||
|
||||
private static string? TryReadId(InstanceDirectoryManager directories, string folder)
|
||||
{
|
||||
try { return directories.LoadInstanceConfig(folder).InstanceId; }
|
||||
catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException) { return null; }
|
||||
}
|
||||
|
||||
private void TryRemove(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Half-imported directory {Path} could not be removed", directory);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Behält die neuesten <paramref name="keep"/> Sicherungen dieser Instanz.</summary>
|
||||
public void Rotate(string ordner, string instanceName, int keep)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user