Просмотр исходного кода

Merge pull request #156 from Timmoth/#155-yaml-migrations

Added YAML migration mechanism
Tim Jones 1 месяц назад
Родитель
Сommit
0f54d40435

+ 0 - 1
RackPeek.Domain/Persistence/InMemoryResourceCollection.cs

@@ -78,7 +78,6 @@ public sealed class InMemoryResourceCollection(IEnumerable<Resource>? seed = nul
         lock (_lock)
         {
             var result = _resources
-                .Where(r => r.Tags != null)
                 .SelectMany(r => r.Tags!) // flatten all tag arrays
                 .Where(t => !string.IsNullOrWhiteSpace(t))
                 .GroupBy(t => t)

+ 140 - 44
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -29,6 +29,9 @@ public sealed class YamlResourceCollection(
     ResourceCollection resourceCollection)
     : IResourceCollection
 {
+    // Bump this when your YAML schema changes, and add a migration step below.
+    private const int CurrentSchemaVersion = 1;
+
     public Task<bool> Exists(string name)
     {
         return Task.FromResult(resourceCollection.Resources.Exists(r =>
@@ -38,11 +41,11 @@ public sealed class YamlResourceCollection(
     public Task<Dictionary<string, int>> GetTagsAsync()
     {
         var result = resourceCollection.Resources
-            .Where(r => r.Tags != null)
-            .SelectMany(r => r.Tags!) // flatten all tag arrays
+            .SelectMany(r => r.Tags) // flatten all tag arrays
             .Where(t => !string.IsNullOrWhiteSpace(t))
             .GroupBy(t => t)
             .ToDictionary(g => g.Key, g => g.Count());
+
         return Task.FromResult(result);
     }
 
@@ -54,28 +57,17 @@ public sealed class YamlResourceCollection(
     public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name)
     {
         return Task.FromResult<IReadOnlyList<Resource>>(resourceCollection.Resources
-            .Where(r => r.RunsOn?.Equals(name, StringComparison.OrdinalIgnoreCase) ?? false).ToList());
-    }
-
-    public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
-    {
-        return Task.FromResult<IReadOnlyList<Resource>>(resourceCollection.Resources.Where(r => r.Tags.Contains(name))
+            .Where(r => r.RunsOn?.Equals(name, StringComparison.OrdinalIgnoreCase) ?? false)
             .ToList());
     }
 
-    public async Task LoadAsync()
+    public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
     {
-        var loaded = await LoadFromFileAsync();
-        try
-        {
-            resourceCollection.Resources.Clear();
-        }
-        catch
-        {
-            // ignore
-        }
-
-        resourceCollection.Resources.AddRange(loaded);
+        return Task.FromResult<IReadOnlyList<Resource>>(
+            resourceCollection.Resources
+                .Where(r => r.Tags.Contains(name))
+                .ToList()
+        );
     }
 
     public IReadOnlyList<Hardware> HardwareResources =>
@@ -97,7 +89,7 @@ public sealed class YamlResourceCollection(
     {
         var resource =
             resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
-        return Task.FromResult<T?>(resource as T);
+        return Task.FromResult(resource as T);
     }
 
     public Resource? GetByName(string name)
@@ -106,6 +98,46 @@ public sealed class YamlResourceCollection(
             r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
     }
 
+    public async Task LoadAsync()
+    {
+        // Read raw YAML so we can back it up exactly before any migration writes.
+        var yaml = await fileStore.ReadAllTextAsync(filePath);
+        if (string.IsNullOrWhiteSpace(yaml))
+        {
+            resourceCollection.Resources.Clear();
+            return;
+        }
+
+        var root = DeserializeRoot(yaml);
+        if (root == null)
+        {
+            // Keep behavior aligned with your previous code: if YAML is invalid, treat as empty.
+            resourceCollection.Resources.Clear();
+            return;
+        }
+
+        // Guard: config is newer than this app understands.
+        if (root.Version > CurrentSchemaVersion)
+        {
+            throw new InvalidOperationException(
+                $"Config schema version {root.Version} is newer than this application supports ({CurrentSchemaVersion}).");
+        }
+
+        // If older, backup first, then migrate step-by-step, then save.
+        if (root.Version < CurrentSchemaVersion)
+        {
+            await BackupOriginalAsync(yaml);
+
+            root = await MigrateAsync(root);
+
+            // Ensure we persist the migrated root (with updated version)
+            await SaveRootAsync(root);
+        }
+
+        resourceCollection.Resources.Clear();
+        resourceCollection.Resources.AddRange(root.Resources ?? []);
+    }
+
     public Task AddAsync(Resource resource)
     {
         return UpdateWithLockAsync(list =>
@@ -143,24 +175,14 @@ public sealed class YamlResourceCollection(
         {
             action(resourceCollection.Resources);
 
-            var serializer = new SerializerBuilder()
-                .WithNamingConvention(CamelCaseNamingConvention.Instance)
-                .WithTypeConverter(new StorageSizeYamlConverter())
-                .WithTypeConverter(new NotesStringYamlConverter())
-                .ConfigureDefaultValuesHandling(
-                    DefaultValuesHandling.OmitNull |
-                    DefaultValuesHandling.OmitEmptyCollections
-                )
-                .Build();
-
-            var payload = new OrderedDictionary
+            // Always write current schema version when app writes the file.
+            var root = new YamlRoot
             {
-                ["resources"] = resourceCollection.Resources.Select(SerializeResource).ToList()
+                Version = CurrentSchemaVersion,
+                Resources = resourceCollection.Resources
             };
 
-            await fileStore.WriteAllTextAsync(
-                filePath,
-                serializer.Serialize(payload));
+            await SaveRootAsync(root);
         }
         finally
         {
@@ -168,12 +190,57 @@ public sealed class YamlResourceCollection(
         }
     }
 
-    private async Task<List<Resource>> LoadFromFileAsync()
+    // ----------------------------
+    // Versioning + migration
+    // ----------------------------
+
+    private async Task BackupOriginalAsync(string originalYaml)
     {
-        var yaml = await fileStore.ReadAllTextAsync(filePath);
-        if (string.IsNullOrWhiteSpace(yaml))
-            return new List<Resource>();
+        // Timestamped backup for safe rollback
+        var backupPath = $"{filePath}.bak.{DateTime.UtcNow:yyyyMMddHHmmss}";
+        await fileStore.WriteAllTextAsync(backupPath, originalYaml);
+    }
+
+    private Task<YamlRoot> MigrateAsync(YamlRoot root)
+    {
+        // Step-by-step migrations until we reach CurrentSchemaVersion
+        while (root.Version < CurrentSchemaVersion)
+        {
+            root = root.Version switch
+            {
+                0 => MigrateV0ToV1(root),
+                _ => throw new InvalidOperationException(
+                    $"No migration is defined from version {root.Version} to {root.Version + 1}.")
+            };
+        }
+
+        return Task.FromResult(root);
+    }
 
+    private YamlRoot MigrateV0ToV1(YamlRoot root)
+    {
+        // V0 -> V1 example migration:
+        // - Ensure 'kind' is normalized on all resources
+        // - Ensure tags collections aren’t null
+        if (root.Resources != null)
+        {
+            foreach (var r in root.Resources)
+            {
+                r.Kind = GetKind(r);
+                r.Tags ??= [];
+            }
+        }
+
+        root.Version = 1;
+        return root;
+    }
+
+    // ----------------------------
+    // YAML read/write
+    // ----------------------------
+
+    private YamlRoot? DeserializeRoot(string yaml)
+    {
         var deserializer = new DeserializerBuilder()
             .WithNamingConvention(CamelCaseNamingConvention.Instance)
             .WithCaseInsensitivePropertyMatching()
@@ -199,15 +266,43 @@ public sealed class YamlResourceCollection(
 
         try
         {
+            // If 'version' is missing, int defaults to 0 => treated as V0.
             var root = deserializer.Deserialize<YamlRoot>(yaml);
-            return root?.Resources ?? new List<Resource>();
+
+            // If YAML had only "resources:" previously, this will still work.
+            root ??= new YamlRoot { Version = 0, Resources = new List<Resource>() };
+            root.Resources ??= new List<Resource>();
+
+            return root;
         }
         catch (YamlException)
         {
-            return new List<Resource>();
+            return null;
         }
     }
 
+    private async Task SaveRootAsync(YamlRoot root)
+    {
+        var serializer = new SerializerBuilder()
+            .WithNamingConvention(CamelCaseNamingConvention.Instance)
+            .WithTypeConverter(new StorageSizeYamlConverter())
+            .WithTypeConverter(new NotesStringYamlConverter())
+            .ConfigureDefaultValuesHandling(
+                DefaultValuesHandling.OmitNull |
+                DefaultValuesHandling.OmitEmptyCollections
+            )
+            .Build();
+
+        // Preserve ordering: version first, then resources
+        var payload = new OrderedDictionary
+        {
+            ["version"] = root.Version,
+            ["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList()
+        };
+
+        await fileStore.WriteAllTextAsync(filePath, serializer.Serialize(payload));
+    }
+
     private string GetKind(Resource resource)
     {
         return resource switch
@@ -249,7 +344,7 @@ public sealed class YamlResourceCollection(
             .Deserialize<Dictionary<string, object?>>(yaml);
 
         foreach (var (key, value) in props)
-            if (key != "kind")
+            if (!string.Equals(key, "kind", StringComparison.OrdinalIgnoreCase))
                 map[key] = value;
 
         return map;
@@ -258,5 +353,6 @@ public sealed class YamlResourceCollection(
 
 public class YamlRoot
 {
+    public int Version { get; set; } // <- NEW: YAML schema version
     public List<Resource>? Resources { get; set; }
-}
+}

+ 1 - 1
RackPeek.Domain/Resources/Resource.cs

@@ -47,7 +47,7 @@ public abstract class Resource
 
     public required string Name { get; set; }
 
-    public string[]? Tags { get; set; } = [];
+    public string[] Tags { get; set; } = [];
     public string? Notes { get; set; }
 
     public string? RunsOn { get; set; }

+ 1 - 3
RackPeek.Domain/UseCases/Tags/AddResourceTagUseCase.cs

@@ -23,9 +23,7 @@ public class AddTagUseCase<T>(IResourceCollection repo) : IAddTagUseCase<T> wher
         if (resource == null)
             throw new NotFoundException($"Resource '{name}' not found.");
 
-        if (resource.Tags == null)
-            resource.Tags = [tag];
-        else if (!resource.Tags.Contains(tag))
+        if (!resource.Tags.Contains(tag))
             resource.Tags = [..resource.Tags, tag];
         else
             // Tag already exists

+ 2 - 2
RackPeek.Domain/UseCases/Tags/RemoveResourceTagUseCase.cs

@@ -24,7 +24,7 @@ public class RemoveTagUseCase<T>(IResourceCollection repo)
         var resource = await repo.GetByNameAsync(name)
                        ?? throw new NotFoundException($"Resource '{name}' not found.");
 
-        if (resource.Tags is null || resource.Tags.Length == 0)
+        if (resource.Tags.Length == 0)
             return;
 
         var updated = resource.Tags
@@ -34,7 +34,7 @@ public class RemoveTagUseCase<T>(IResourceCollection repo)
         if (updated.Length == resource.Tags.Length)
             return; // tag didn't exist
 
-        resource.Tags = updated.Length == 0 ? null : updated;
+        resource.Tags = updated;
 
         await repo.UpdateAsync(resource);
     }

+ 1 - 1
Shared.Rcl/Components/ResourceTagEditor.razor

@@ -17,7 +17,7 @@
         </div>
     </div>
 
-    @if (Resource.Tags?.Any() == true)
+    @if (Resource.Tags.Any())
     {
         <div class="flex flex-wrap gap-2">
             @foreach (var tag in Resource.Tags.OrderBy(t => t))

+ 2 - 0
Tests/EndToEnd/AccessPointE2ETests.cs

@@ -41,6 +41,7 @@ public class AccessPointYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper ou
         Assert.Equal("Access Point 'ap01' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite
@@ -61,6 +62,7 @@ public class AccessPointYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper ou
         Assert.Equal("Access Point 'ap02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite

+ 2 - 0
Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs

@@ -38,6 +38,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("Access Point 'ap01' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite
@@ -57,6 +58,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("Access Point 'ap02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite

+ 3 - 0
Tests/EndToEnd/ServiceYamlE2ETests.cs

@@ -29,6 +29,7 @@ public class ServiceYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper output
         var (output, yaml) = await ExecuteAsync("services", "add", "immich");
         Assert.Equal("Service 'immich' added.\n", output);
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Service
                        name: immich
@@ -44,6 +45,7 @@ public class ServiceYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper output
 
         outputHelper.WriteLine(yaml);
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Service
                        network:
@@ -66,6 +68,7 @@ public class ServiceYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper output
                      """, output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: System
                        name: vm01

+ 2 - 0
Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs

@@ -45,6 +45,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw01' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Switch
                        model: Netgear GS108
@@ -68,6 +69,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Switch
                        model: Netgear GS108

+ 2 - 0
Tests/EndToEnd/SwitchYamlE2ETests.cs

@@ -38,6 +38,7 @@ public class SwitchYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputH
             "--poe", "true");
         Assert.Equal("Switch 'sw01' updated.\n", output);
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Switch
                        model: Netgear GS108
@@ -56,6 +57,7 @@ public class SwitchYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputH
         Assert.Equal("Switch 'sw02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Switch
                        model: Netgear GS108

+ 2 - 0
Tests/EndToEnd/SystemYamlE2ETests.cs

@@ -33,6 +33,7 @@ public class SystemYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputH
         (output, yaml) = await ExecuteAsync("systems", "add", "host01");
         Assert.Equal("System 'host01' added.\n", output);
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Server
                        name: hypervisor01
@@ -55,6 +56,7 @@ public class SystemYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputH
 
         outputHelper.WriteLine(yaml);
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Server
                        name: hypervisor01

+ 2 - 0
Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs

@@ -40,6 +40,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
         Assert.Equal("UPS 'ups01' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Ups
                        model: APC-SmartUPS-1500
@@ -60,6 +61,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
         Assert.Equal("UPS 'ups02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Ups
                        model: APC-SmartUPS-1500

+ 2 - 0
Tests/EndToEnd/UpsYamlE2ETests.cs

@@ -43,6 +43,7 @@ public class UpsYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputHelp
         Assert.Equal("UPS 'ups01' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Ups
                        model: APC Smart-UPS 1500
@@ -64,6 +65,7 @@ public class UpsYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputHelp
         Assert.Equal("UPS 'ups02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Ups
                        model: APC Smart-UPS 1500