summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Recovery/TaskResourceJournal.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.Windows/Recovery/TaskResourceJournal.cs')
-rw-r--r--src/Agent.Windows/Recovery/TaskResourceJournal.cs108
1 files changed, 108 insertions, 0 deletions
diff --git a/src/Agent.Windows/Recovery/TaskResourceJournal.cs b/src/Agent.Windows/Recovery/TaskResourceJournal.cs
new file mode 100644
index 0000000..b638041
--- /dev/null
+++ b/src/Agent.Windows/Recovery/TaskResourceJournal.cs
@@ -0,0 +1,108 @@
+using System.Text.Json;
+
+namespace Agent.Windows.Recovery;
+
+public enum JournalEntryState { Prepared, Applied, Cleaned }
+
+public sealed class JournalEntry
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Kind { get; set; } = string.Empty;
+ public string Target { get; set; } = string.Empty;
+ public JournalEntryState State { get; set; }
+ public Dictionary<string, string> Data { get; set; } = new(StringComparer.Ordinal);
+}
+
+public sealed class JournalDocument
+{
+ public int SchemaVersion { get; set; } = 1;
+ public Guid TaskId { get; set; }
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public List<JournalEntry> Entries { get; set; } = [];
+}
+
+public sealed class TaskResourceJournal
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+ private readonly object _sync = new();
+ private readonly JournalDocument _document;
+
+ private TaskResourceJournal(string path, JournalDocument document) { Path = path; _document = document; }
+ public string Path { get; }
+ public Guid TaskId => _document.TaskId;
+ public IReadOnlyList<JournalEntry> Entries { get { lock (_sync) return _document.Entries.Select(Clone).ToArray(); } }
+
+ public static TaskResourceJournal Create(string journalDirectory, Guid taskId)
+ {
+ Directory.CreateDirectory(journalDirectory);
+ string path = System.IO.Path.Combine(journalDirectory, taskId.ToString("N") + ".journal.json");
+ if (File.Exists(path)) throw new InvalidOperationException($"Journal already exists for task '{taskId}'.");
+ var journal = new TaskResourceJournal(path, new JournalDocument { TaskId = taskId });
+ journal.Persist();
+ return journal;
+ }
+
+ public static TaskResourceJournal Open(string path)
+ {
+ JournalDocument document = JsonSerializer.Deserialize<JournalDocument>(File.ReadAllText(path), JsonOptions)
+ ?? throw new InvalidDataException($"Journal '{path}' is empty.");
+ if (document.SchemaVersion != 1 || document.TaskId == Guid.Empty) throw new InvalidDataException($"Journal '{path}' is invalid.");
+ return new(path, document);
+ }
+
+ public Guid Prepare(string kind, string target, IReadOnlyDictionary<string, string> data)
+ {
+ lock (_sync)
+ {
+ var entry = new JournalEntry { Kind = kind, Target = target, State = JournalEntryState.Prepared, Data = new(data, StringComparer.Ordinal) };
+ _document.Entries.Add(entry);
+ Persist();
+ return entry.Id;
+ }
+ }
+
+ public void MarkApplied(Guid entryId, IReadOnlyDictionary<string, string>? additionalData = null)
+ {
+ lock (_sync)
+ {
+ JournalEntry entry = Get(entryId);
+ if (entry.State != JournalEntryState.Prepared) throw new InvalidOperationException("Only prepared resources can be marked applied.");
+ if (additionalData is not null) foreach ((string key, string value) in additionalData) entry.Data[key] = value;
+ entry.State = JournalEntryState.Applied;
+ Persist();
+ }
+ }
+
+ public void MarkCleaned(Guid entryId)
+ {
+ lock (_sync)
+ {
+ JournalEntry entry = Get(entryId);
+ if (entry.State == JournalEntryState.Cleaned) return;
+ entry.State = JournalEntryState.Cleaned;
+ Persist();
+ }
+ }
+
+ public void CompleteAndDelete()
+ {
+ lock (_sync)
+ {
+ if (_document.Entries.Any(entry => entry.State != JournalEntryState.Cleaned)) throw new InvalidOperationException("Journal still contains live resources.");
+ File.Delete(Path);
+ }
+ }
+
+ private JournalEntry Get(Guid id) => _document.Entries.SingleOrDefault(entry => entry.Id == id) ?? throw new KeyNotFoundException($"Journal entry '{id}' not found.");
+ private void Persist()
+ {
+ string temporary = Path + ".tmp";
+ using (FileStream stream = new(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
+ {
+ JsonSerializer.Serialize(stream, _document, JsonOptions);
+ stream.Flush(flushToDisk: true);
+ }
+ File.Move(temporary, Path, overwrite: true);
+ }
+ private static JournalEntry Clone(JournalEntry source) => new() { Id = source.Id, Kind = source.Kind, Target = source.Target, State = source.State, Data = new(source.Data, StringComparer.Ordinal) };
+}