summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Recovery/TaskResourceJournal.cs
blob: b6380412340042e78683acc2b281401acc65c13f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
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) };
}