387 lines
15 KiB
C#
387 lines
15 KiB
C#
using System.Text.Json;
|
|
|
|
namespace CodexProviderSync.Core;
|
|
|
|
public sealed class BackupService
|
|
{
|
|
private readonly SessionRolloutService _sessionRolloutService;
|
|
private readonly SqliteStateService _sqliteStateService;
|
|
|
|
public BackupService(SessionRolloutService sessionRolloutService, SqliteStateService sqliteStateService)
|
|
{
|
|
_sessionRolloutService = sessionRolloutService;
|
|
_sqliteStateService = sqliteStateService;
|
|
}
|
|
|
|
public async Task<string> CreateBackupAsync(
|
|
string codexHome,
|
|
string targetProvider,
|
|
IReadOnlyList<SessionChange> sessionChanges,
|
|
string configPath,
|
|
string? configBackupText = null)
|
|
{
|
|
string backupRoot = AppConstants.DefaultBackupRoot(codexHome);
|
|
string backupDir = Path.Combine(backupRoot, DateTimeOffset.UtcNow.ToString("yyyyMMdd'T'HHmmssfff'Z'"));
|
|
string dbDir = Path.Combine(backupDir, "db");
|
|
Directory.CreateDirectory(dbDir);
|
|
|
|
List<string> copiedDbFiles = [];
|
|
StateDbLocation? stateDb = _sqliteStateService.DetectStateDb(codexHome);
|
|
if (stateDb is not null)
|
|
{
|
|
foreach (string suffix in new[] { string.Empty, "-shm", "-wal" })
|
|
{
|
|
string relativePath = DbBackupRelativePath(codexHome, stateDb.Path, suffix);
|
|
if (await CopyIfPresentAsync(stateDb.Path + suffix, Path.Combine(dbDir, relativePath), overwrite: false))
|
|
{
|
|
copiedDbFiles.Add(relativePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
string configBackupPath = Path.Combine(backupDir, "config.toml");
|
|
if (configBackupText is not null)
|
|
{
|
|
await File.WriteAllTextAsync(configBackupPath, configBackupText);
|
|
}
|
|
else
|
|
{
|
|
await CopyIfPresentAsync(configPath, configBackupPath, overwrite: false);
|
|
}
|
|
await CopyIfPresentAsync(
|
|
Path.Combine(codexHome, AppConstants.GlobalStateFileBasename),
|
|
Path.Combine(backupDir, AppConstants.GlobalStateFileBasename),
|
|
overwrite: false);
|
|
await CopyIfPresentAsync(
|
|
Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename),
|
|
Path.Combine(backupDir, AppConstants.GlobalStateBackupFileBasename),
|
|
overwrite: false);
|
|
|
|
DateTimeOffset createdAt = DateTimeOffset.UtcNow;
|
|
SessionBackupManifest sessionManifest = new()
|
|
{
|
|
Version = 1,
|
|
Namespace = AppConstants.BackupNamespace,
|
|
CodexHome = codexHome,
|
|
TargetProvider = targetProvider,
|
|
CreatedAt = createdAt,
|
|
Files = sessionChanges.Select(static change => new SessionBackupManifestEntry
|
|
{
|
|
Path = change.Path,
|
|
OriginalFirstLine = change.OriginalFirstLine,
|
|
OriginalSeparator = change.OriginalSeparator,
|
|
OriginalLastWriteTimeUtcTicks = change.OriginalLastWriteTimeUtcTicks
|
|
}).ToList()
|
|
};
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(backupDir, "session-meta-backup.json"),
|
|
JsonSerializer.Serialize(sessionManifest, JsonOptions()));
|
|
|
|
BackupMetadataFile metadata = new()
|
|
{
|
|
Version = 1,
|
|
Namespace = AppConstants.BackupNamespace,
|
|
CodexHome = codexHome,
|
|
TargetProvider = targetProvider,
|
|
CreatedAt = createdAt,
|
|
DbFiles = copiedDbFiles,
|
|
ChangedSessionFiles = sessionChanges.Count
|
|
};
|
|
await File.WriteAllTextAsync(
|
|
Path.Combine(backupDir, "metadata.json"),
|
|
JsonSerializer.Serialize(metadata, JsonOptions()));
|
|
|
|
return backupDir;
|
|
}
|
|
|
|
public async Task<RestoreResult> RestoreBackupAsync(
|
|
string backupDir,
|
|
string codexHome,
|
|
RestoreBackupOptions? options = null)
|
|
{
|
|
options ??= new RestoreBackupOptions();
|
|
string normalizedBackupDir = Path.GetFullPath(backupDir);
|
|
BackupMetadataFile metadata = JsonSerializer.Deserialize<BackupMetadataFile>(
|
|
await File.ReadAllTextAsync(Path.Combine(normalizedBackupDir, "metadata.json")),
|
|
JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}");
|
|
|
|
if (!string.Equals(metadata.CodexHome, codexHome, StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException($"Backup was created for {metadata.CodexHome}, not {codexHome}.");
|
|
}
|
|
|
|
SessionBackupManifest? sessionManifest = null;
|
|
if (options.RestoreSessions)
|
|
{
|
|
sessionManifest = JsonSerializer.Deserialize<SessionBackupManifest>(
|
|
await File.ReadAllTextAsync(Path.Combine(normalizedBackupDir, "session-meta-backup.json")),
|
|
JsonOptions()) ?? throw new InvalidOperationException($"Session backup manifest is invalid: {backupDir}");
|
|
|
|
await _sessionRolloutService.AssertSessionFilesWritableAsync(
|
|
sessionManifest.Files.Select(static entry => entry.Path));
|
|
}
|
|
|
|
if (options.RestoreConfig)
|
|
{
|
|
await CopyIfPresentAsync(
|
|
Path.Combine(normalizedBackupDir, "config.toml"),
|
|
Path.Combine(codexHome, "config.toml"),
|
|
overwrite: true);
|
|
await CopyIfPresentAsync(
|
|
Path.Combine(normalizedBackupDir, AppConstants.GlobalStateFileBasename),
|
|
Path.Combine(codexHome, AppConstants.GlobalStateFileBasename),
|
|
overwrite: true);
|
|
await CopyIfPresentAsync(
|
|
Path.Combine(normalizedBackupDir, AppConstants.GlobalStateBackupFileBasename),
|
|
Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename),
|
|
overwrite: true);
|
|
}
|
|
|
|
if (options.RestoreDatabase)
|
|
{
|
|
await _sqliteStateService.AssertSqliteWritableAsync(codexHome);
|
|
string dbDir = Path.Combine(normalizedBackupDir, "db");
|
|
HashSet<string> backedUpFiles = new(metadata.DbFiles, StringComparer.Ordinal);
|
|
|
|
foreach (string baseFile in metadata.DbFiles.Where(static fileName => Path.GetFileName(fileName) == AppConstants.DbFileBasename))
|
|
{
|
|
string basePath = RestoreDbTargetPath(codexHome, baseFile);
|
|
foreach (string suffix in new[] { "-shm", "-wal" })
|
|
{
|
|
string sidecarFile = baseFile + suffix;
|
|
string sidecarPath = basePath + suffix;
|
|
if (!backedUpFiles.Contains(sidecarFile) && File.Exists(sidecarPath))
|
|
{
|
|
File.Delete(sidecarPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (string fileName in metadata.DbFiles)
|
|
{
|
|
await CopyIfPresentAsync(Path.Combine(dbDir, fileName), RestoreDbTargetPath(codexHome, fileName), overwrite: true);
|
|
}
|
|
}
|
|
|
|
if (options.RestoreSessions && sessionManifest is not null)
|
|
{
|
|
await _sessionRolloutService.RestoreSessionChangesAsync(sessionManifest.Files);
|
|
}
|
|
|
|
return new RestoreResult
|
|
{
|
|
CodexHome = codexHome,
|
|
BackupDir = normalizedBackupDir,
|
|
TargetProvider = metadata.TargetProvider,
|
|
CreatedAt = metadata.CreatedAt,
|
|
ChangedSessionFiles = metadata.ChangedSessionFiles
|
|
};
|
|
}
|
|
|
|
public async Task UpdateSessionBackupManifestAsync(string backupDir, IReadOnlyList<SessionChange> sessionChanges)
|
|
{
|
|
string normalizedBackupDir = Path.GetFullPath(backupDir);
|
|
string manifestPath = Path.Combine(normalizedBackupDir, "session-meta-backup.json");
|
|
string metadataPath = Path.Combine(normalizedBackupDir, "metadata.json");
|
|
|
|
SessionBackupManifest sessionManifest = JsonSerializer.Deserialize<SessionBackupManifest>(
|
|
await File.ReadAllTextAsync(manifestPath),
|
|
JsonOptions()) ?? throw new InvalidOperationException($"Session backup manifest is invalid: {backupDir}");
|
|
BackupMetadataFile metadata = JsonSerializer.Deserialize<BackupMetadataFile>(
|
|
await File.ReadAllTextAsync(metadataPath),
|
|
JsonOptions()) ?? throw new InvalidOperationException($"Backup metadata is invalid: {backupDir}");
|
|
|
|
sessionManifest = new SessionBackupManifest
|
|
{
|
|
Version = sessionManifest.Version,
|
|
Namespace = sessionManifest.Namespace,
|
|
CodexHome = sessionManifest.CodexHome,
|
|
TargetProvider = sessionManifest.TargetProvider,
|
|
CreatedAt = sessionManifest.CreatedAt,
|
|
Files = sessionChanges.Select(static change => new SessionBackupManifestEntry
|
|
{
|
|
Path = change.Path,
|
|
OriginalFirstLine = change.OriginalFirstLine,
|
|
OriginalSeparator = change.OriginalSeparator,
|
|
OriginalLastWriteTimeUtcTicks = change.OriginalLastWriteTimeUtcTicks
|
|
}).ToList()
|
|
};
|
|
metadata = new BackupMetadataFile
|
|
{
|
|
Version = metadata.Version,
|
|
Namespace = metadata.Namespace,
|
|
CodexHome = metadata.CodexHome,
|
|
TargetProvider = metadata.TargetProvider,
|
|
CreatedAt = metadata.CreatedAt,
|
|
DbFiles = metadata.DbFiles,
|
|
ChangedSessionFiles = sessionChanges.Count
|
|
};
|
|
|
|
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(sessionManifest, JsonOptions()));
|
|
await File.WriteAllTextAsync(metadataPath, JsonSerializer.Serialize(metadata, JsonOptions()));
|
|
}
|
|
|
|
public async Task RestoreGlobalStateFilesAsync(string backupDir, string codexHome)
|
|
{
|
|
string normalizedBackupDir = Path.GetFullPath(backupDir);
|
|
await CopyIfPresentAsync(
|
|
Path.Combine(normalizedBackupDir, AppConstants.GlobalStateFileBasename),
|
|
Path.Combine(codexHome, AppConstants.GlobalStateFileBasename),
|
|
overwrite: true);
|
|
await CopyIfPresentAsync(
|
|
Path.Combine(normalizedBackupDir, AppConstants.GlobalStateBackupFileBasename),
|
|
Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename),
|
|
overwrite: true);
|
|
}
|
|
|
|
public Task<BackupSummary> GetBackupSummaryAsync(string codexHome)
|
|
{
|
|
string backupRoot = AppConstants.DefaultBackupRoot(codexHome);
|
|
return Task.Run(() =>
|
|
{
|
|
if (!Directory.Exists(backupRoot))
|
|
{
|
|
return new BackupSummary
|
|
{
|
|
Count = 0,
|
|
TotalBytes = 0
|
|
};
|
|
}
|
|
|
|
List<DirectoryInfo> entries = GetManagedBackupDirectories(backupRoot);
|
|
long totalBytes = entries.Sum(static entry => GetDirectorySize(entry.FullName));
|
|
|
|
return new BackupSummary
|
|
{
|
|
Count = entries.Count,
|
|
TotalBytes = totalBytes
|
|
};
|
|
});
|
|
}
|
|
|
|
public Task<BackupPruneResult> PruneBackupsAsync(string codexHome, int keepCount = AppConstants.DefaultBackupRetentionCount)
|
|
{
|
|
if (keepCount < 0)
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(keepCount), keepCount, "keepCount must be 0 or greater.");
|
|
}
|
|
|
|
string backupRoot = AppConstants.DefaultBackupRoot(codexHome);
|
|
return Task.Run(() =>
|
|
{
|
|
if (!Directory.Exists(backupRoot))
|
|
{
|
|
return new BackupPruneResult
|
|
{
|
|
BackupRoot = backupRoot,
|
|
DeletedCount = 0,
|
|
RemainingCount = 0,
|
|
FreedBytes = 0
|
|
};
|
|
}
|
|
|
|
List<DirectoryInfo> entries = GetManagedBackupDirectories(backupRoot);
|
|
|
|
List<DirectoryInfo> toDelete = entries.Skip(keepCount).ToList();
|
|
long freedBytes = 0;
|
|
foreach (DirectoryInfo entry in toDelete)
|
|
{
|
|
freedBytes += GetDirectorySize(entry.FullName);
|
|
entry.Delete(recursive: true);
|
|
}
|
|
|
|
return new BackupPruneResult
|
|
{
|
|
BackupRoot = backupRoot,
|
|
DeletedCount = toDelete.Count,
|
|
RemainingCount = entries.Count - toDelete.Count,
|
|
FreedBytes = freedBytes
|
|
};
|
|
});
|
|
}
|
|
|
|
private static async Task<bool> CopyIfPresentAsync(string sourcePath, string destinationPath, bool overwrite)
|
|
{
|
|
if (!File.Exists(sourcePath))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
|
File.Copy(sourcePath, destinationPath, overwrite);
|
|
await Task.CompletedTask;
|
|
return true;
|
|
}
|
|
|
|
private static string DbBackupRelativePath(string codexHome, string dbPath, string suffix)
|
|
{
|
|
string relativePath = Path.GetRelativePath(codexHome, dbPath + suffix);
|
|
return !relativePath.StartsWith("..", StringComparison.Ordinal)
|
|
&& !Path.IsPathRooted(relativePath)
|
|
? relativePath
|
|
: AppConstants.DbFileBasename + suffix;
|
|
}
|
|
|
|
private static string RestoreDbTargetPath(string codexHome, string relativePath)
|
|
{
|
|
if (Path.IsPathRooted(relativePath)
|
|
|| relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Contains("..", StringComparer.Ordinal))
|
|
{
|
|
throw new InvalidOperationException($"Invalid database backup path: {relativePath}");
|
|
}
|
|
|
|
return Path.Combine(codexHome, relativePath);
|
|
}
|
|
|
|
private static JsonSerializerOptions JsonOptions()
|
|
{
|
|
return new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = true
|
|
};
|
|
}
|
|
|
|
private static long GetDirectorySize(string directoryPath)
|
|
{
|
|
if (!Directory.Exists(directoryPath))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
return Directory
|
|
.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories)
|
|
.Sum(static filePath => new FileInfo(filePath).Length);
|
|
}
|
|
|
|
private static List<DirectoryInfo> GetManagedBackupDirectories(string backupRoot)
|
|
{
|
|
return new DirectoryInfo(backupRoot)
|
|
.EnumerateDirectories()
|
|
.Where(static entry => IsManagedBackupDirectory(entry.FullName))
|
|
.OrderByDescending(static entry => entry.Name, StringComparer.Ordinal)
|
|
.ToList();
|
|
}
|
|
|
|
private static bool IsManagedBackupDirectory(string backupDirectoryPath)
|
|
{
|
|
string metadataPath = Path.Combine(backupDirectoryPath, "metadata.json");
|
|
if (!File.Exists(metadataPath))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
BackupMetadataFile? metadata = JsonSerializer.Deserialize<BackupMetadataFile>(
|
|
File.ReadAllText(metadataPath),
|
|
JsonOptions());
|
|
return string.Equals(metadata?.Namespace, AppConstants.BackupNamespace, StringComparison.Ordinal);
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|