From 3d68f3f7891ac702395dda39fbc5800ef9c1810f Mon Sep 17 00:00:00 2001 From: Arael Espinosa Date: Thu, 30 Jul 2026 14:52:06 -0300 Subject: [PATCH] feat: add `dns-sync fmt` command with semantic record sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zone YAML files can now be reformatted to a canonical style with `dns-sync fmt [DIR]`. Records are sorted semantically by type (NS → A → AAAA → CNAME → MX → TXT → SRV → CAA), hostnames are sorted alphabetically with apex first, and MX values are ordered by preference ascending. Includes --check flag for CI validation. The sorting logic lives in ZoneYamlSerializer.Serialize so imports also produce canonically sorted output. 12 new tests cover the command, sorting order, idempotency, and edge cases. --- src/DnsSync/Commands/FmtCommand.cs | 94 ++++ src/DnsSync/Commands/FmtSettings.cs | 16 + src/DnsSync/Program.cs | 5 + .../Providers/Yaml/ZoneYamlSerializer.cs | 42 +- .../DnsSync.Tests/Commands/FmtCommandTests.cs | 421 ++++++++++++++++++ 5 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 src/DnsSync/Commands/FmtCommand.cs create mode 100644 src/DnsSync/Commands/FmtSettings.cs create mode 100644 tests/DnsSync.Tests/Commands/FmtCommandTests.cs diff --git a/src/DnsSync/Commands/FmtCommand.cs b/src/DnsSync/Commands/FmtCommand.cs new file mode 100644 index 0000000..3bf7abc --- /dev/null +++ b/src/DnsSync/Commands/FmtCommand.cs @@ -0,0 +1,94 @@ +using DnsSync.Core; +using DnsSync.Providers.Yaml; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace DnsSync.Commands; + +public class FmtCommand : Command +{ + protected override int Execute(CommandContext context, FmtSettings settings, CancellationToken cancellationToken) + { + var directory = Path.GetFullPath(settings.Directory); + + if (!System.IO.Directory.Exists(directory)) + { + AnsiConsole.MarkupLine($"[red]✗[/] Directory not found: {Markup.Escape(directory)}"); + return 1; + } + + var files = System.IO.Directory.GetFiles(directory, "*.yaml") + .OrderBy(f => f) + .ToList(); + + if (files.Count == 0) + { + AnsiConsole.MarkupLine($"[yellow]~[/] No .yaml files found in {Markup.Escape(directory)}"); + return 0; + } + + var changed = 0; + var unchanged = 0; + + foreach (var file in files) + { + var fileName = Path.GetFileNameWithoutExtension(file); + var zoneName = fileName + "."; + var original = File.ReadAllText(file); + + IReadOnlyList records; + try + { + records = YamlProvider.ParseZoneYaml(original, zoneName); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($" [red]✗[/] {Markup.Escape(fileName)}.yaml, parse error: {Markup.Escape(ex.Message)}"); + continue; + } + + var zone = new DnsZone { Name = zoneName, Records = records.ToList() }; + var formatted = ZoneYamlSerializer.Serialize(zone); + + if (formatted == original) + { + unchanged++; + continue; + } + + changed++; + + if (settings.Check) + { + AnsiConsole.MarkupLine($" [yellow]~[/] {Markup.Escape(fileName)}.yaml would be reformatted"); + } + else + { + File.WriteAllText(file, formatted); + AnsiConsole.MarkupLine($" [green]✓[/] {Markup.Escape(fileName)}.yaml"); + } + } + + AnsiConsole.WriteLine(); + + if (settings.Check) + { + if (changed > 0) + { + AnsiConsole.MarkupLine($"[yellow]~[/] {changed} file(s) would be reformatted"); + return 1; + } + + AnsiConsole.MarkupLine($"[green]✓[/] All {unchanged} file(s) already formatted"); + return 0; + } + + if (changed > 0) + AnsiConsole.MarkupLine($"[green]✓[/] Reformatted [bold]{changed}[/] file(s)"); + + if (unchanged > 0) + AnsiConsole.MarkupLine($"[dim]{unchanged} file(s) already formatted[/]"); + + return 0; + } +} diff --git a/src/DnsSync/Commands/FmtSettings.cs b/src/DnsSync/Commands/FmtSettings.cs new file mode 100644 index 0000000..f61540f --- /dev/null +++ b/src/DnsSync/Commands/FmtSettings.cs @@ -0,0 +1,16 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace DnsSync.Commands; + +public class FmtSettings : CommandSettings +{ + [CommandArgument(0, "[DIRECTORY]")] + [Description("Directory containing zone YAML files (default: ./zones)")] + [DefaultValue("./zones")] + public string Directory { get; set; } = "./zones"; + + [CommandOption("--check")] + [Description("Check formatting without writing changes (exit 1 if files would change)")] + public bool Check { get; set; } +} diff --git a/src/DnsSync/Program.cs b/src/DnsSync/Program.cs index 7c0d99c..dea549b 100644 --- a/src/DnsSync/Program.cs +++ b/src/DnsSync/Program.cs @@ -154,6 +154,11 @@ .WithExample(["diff", "--from", "cloudflare", "--to", "route53", "--config", "config.yaml"]) .WithExample(["diff", "--from", "cloudflare", "--to", "porkbun", "--zone", "example.com.", "--config", "config.yaml"]); + config.AddCommand("fmt") + .WithDescription("Reformat zone YAML files to canonical sorted style") + .WithExample(["fmt"]) + .WithExample(["fmt", "./zones", "--check"]); + config.AddCommand("drift") .WithDescription("Detect DNS record drift from desired state without applying changes") .WithExample(["drift", "--config", "config.yaml"]) diff --git a/src/DnsSync/Providers/Yaml/ZoneYamlSerializer.cs b/src/DnsSync/Providers/Yaml/ZoneYamlSerializer.cs index 86a97ad..7d5986e 100644 --- a/src/DnsSync/Providers/Yaml/ZoneYamlSerializer.cs +++ b/src/DnsSync/Providers/Yaml/ZoneYamlSerializer.cs @@ -10,6 +10,37 @@ namespace DnsSync.Providers.Yaml; /// public static class ZoneYamlSerializer { + private static readonly Dictionary RecordTypeOrder = new(StringComparer.OrdinalIgnoreCase) + { + ["SOA"] = 0, + ["NS"] = 1, + ["A"] = 2, + ["AAAA"] = 3, + ["CNAME"] = 4, + ["MX"] = 5, + ["TXT"] = 6, + ["SRV"] = 7, + ["CAA"] = 8, + }; + + private static int GetTypeOrder(string type) => + RecordTypeOrder.TryGetValue(type, out var order) ? order : 100; + + /// + /// Returns a sorted copy of the record with MX values ordered by preference ascending. + /// Non-MX records are returned as-is. + /// + private static DnsRecord SortRecordValues(DnsRecord record) + { + if (record is MxRecord mx && mx.Values.Count > 1) + { + var sorted = mx.Values.OrderBy(v => v.Preference).ThenBy(v => v.Exchange, StringComparer.OrdinalIgnoreCase).ToList(); + return new MxRecord { Name = mx.Name, Type = mx.Type, Ttl = mx.Ttl, Values = sorted }; + } + + return record; + } + public static string Serialize(DnsZone zone, string? providerName = null) { var sb = new StringBuilder(); @@ -18,15 +49,20 @@ public static string Serialize(DnsZone zone, string? providerName = null) sb.AppendLine($"# Imported {fromClause}by dns-sync on {DateTime.UtcNow:yyyy-MM-dd}"); sb.AppendLine(); - // Group records by subdomain key for output + // Group records by subdomain key, apex first then alphabetical var bySubdomain = zone.Records .GroupBy(r => SubdomainKey(r.Name, zone.Name)) - .OrderBy(g => g.Key == "" ? "\0" : g.Key); // apex first + .OrderBy(g => g.Key == "" ? "\0" : g.Key); // apex first, then alphabetical foreach (var group in bySubdomain) { var key = group.Key; - var records = group.ToList(); + // Sort records within each subdomain: semantic type order, then alphabetical + var records = group + .Select(SortRecordValues) + .OrderBy(r => GetTypeOrder(r.Type)) + .ThenBy(r => r.Type, StringComparer.OrdinalIgnoreCase) + .ToList(); var yamlKey = key == "" ? "''" : key; if (records.Count == 1) diff --git a/tests/DnsSync.Tests/Commands/FmtCommandTests.cs b/tests/DnsSync.Tests/Commands/FmtCommandTests.cs new file mode 100644 index 0000000..2db6bba --- /dev/null +++ b/tests/DnsSync.Tests/Commands/FmtCommandTests.cs @@ -0,0 +1,421 @@ +using DnsSync.Commands; +using DnsSync.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shouldly; +using Spectre.Console; +using Spectre.Console.Cli; +using Spectre.Console.Testing; + +namespace DnsSync.Tests.Commands; + +[Collection("cli-serial")] +public class FmtCommandTests : IDisposable +{ + private readonly string _tmp = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + private readonly TestConsole _console = new(); + private readonly IAnsiConsole _previousConsole; + + public FmtCommandTests() + { + Directory.CreateDirectory(_tmp); + _previousConsole = AnsiConsole.Console; + AnsiConsole.Console = _console; + } + + public void Dispose() + { + AnsiConsole.Console = _previousConsole; + if (Directory.Exists(_tmp)) Directory.Delete(_tmp, recursive: true); + } + + private CommandApp BuildApp() + { + var services = new ServiceCollection(); + services.AddLogging(b => b.ClearProviders()); + var registrar = new TypeRegistrar(services); + var app = new CommandApp(registrar); + app.Configure(cfg => cfg.AddCommand("fmt")); + return app; + } + + private void WriteZone(string fileName, string content) + { + File.WriteAllText(Path.Combine(_tmp, fileName), content); + } + + [Fact] + public async Task Fmt_SortsRecordTypesSemantically() + { + // TXT before A, should become A before TXT + WriteZone("example.com.yaml", """ + # Zone: example.com. + # Imported by dns-sync on 2026-01-01 + + '': + - + type: TXT + ttl: 600 + value: hello + - + type: A + ttl: 600 + value: 1.2.3.4 + """.Replace(" ", "")); + + var app = BuildApp(); + var result = await app.RunAsync(["fmt", _tmp]); + + result.ShouldBe(0); + + var output = File.ReadAllText(Path.Combine(_tmp, "example.com.yaml")); + var aPos = output.IndexOf("type: A", StringComparison.Ordinal); + var txtPos = output.IndexOf("type: TXT", StringComparison.Ordinal); + aPos.ShouldBeLessThan(txtPos, "A record should appear before TXT"); + } + + [Fact] + public async Task Fmt_SortsNsBeforeA() + { + WriteZone("example.com.yaml", """ + # Zone: example.com. + # Imported by dns-sync on 2026-01-01 + + '': + - + type: A + ttl: 600 + value: 1.2.3.4 + - + type: NS + ttl: 3600 + values: + - ns1.example.com. + - ns2.example.com. + """.Replace(" ", "")); + + var app = BuildApp(); + await app.RunAsync(["fmt", _tmp]); + + var output = File.ReadAllText(Path.Combine(_tmp, "example.com.yaml")); + var nsPos = output.IndexOf("type: NS", StringComparison.Ordinal); + var aPos = output.IndexOf("type: A", StringComparison.Ordinal); + nsPos.ShouldBeLessThan(aPos, "NS should appear before A"); + } + + [Fact] + public async Task Fmt_SortsMxByPreferenceAscending() + { + WriteZone("example.com.yaml", """ + # Zone: example.com. + # Imported by dns-sync on 2026-01-01 + + '': + type: MX + ttl: 3600 + values: + - preference: 20 + exchange: mx2.example.com. + - preference: 5 + exchange: mx1.example.com. + - preference: 10 + exchange: mx3.example.com. + """.Replace(" ", "")); + + var app = BuildApp(); + await app.RunAsync(["fmt", _tmp]); + + var output = File.ReadAllText(Path.Combine(_tmp, "example.com.yaml")); + var mx1Pos = output.IndexOf("mx1.example.com.", StringComparison.Ordinal); + var mx3Pos = output.IndexOf("mx3.example.com.", StringComparison.Ordinal); + var mx2Pos = output.IndexOf("mx2.example.com.", StringComparison.Ordinal); + mx1Pos.ShouldBeLessThan(mx3Pos, "preference 5 before 10"); + mx3Pos.ShouldBeLessThan(mx2Pos, "preference 10 before 20"); + } + + [Fact] + public async Task Fmt_SortsSubdomainsAlphabetically_ApexFirst() + { + WriteZone("example.com.yaml", """ + # Zone: example.com. + # Imported by dns-sync on 2026-01-01 + + www: + type: A + ttl: 600 + value: 1.2.3.4 + + '': + type: A + ttl: 600 + value: 5.6.7.8 + + api: + type: A + ttl: 600 + value: 9.10.11.12 + """.Replace(" ", "")); + + var app = BuildApp(); + await app.RunAsync(["fmt", _tmp]); + + var output = File.ReadAllText(Path.Combine(_tmp, "example.com.yaml")); + var apexPos = output.IndexOf("'':", StringComparison.Ordinal); + var apiPos = output.IndexOf("api:", StringComparison.Ordinal); + var wwwPos = output.IndexOf("www:", StringComparison.Ordinal); + apexPos.ShouldBeLessThan(apiPos, "apex before api"); + apiPos.ShouldBeLessThan(wwwPos, "api before www"); + } + + [Fact] + public async Task Fmt_IsIdempotent() + { + WriteZone("example.com.yaml", """ + # Zone: example.com. + # Imported by dns-sync on 2026-01-01 + + '': + - + type: TXT + ttl: 600 + value: hello + - + type: A + ttl: 600 + value: 1.2.3.4 + + www: + type: CNAME + ttl: 600 + value: example.com. + """.Replace(" ", "")); + + var app = BuildApp(); + await app.RunAsync(["fmt", _tmp]); + + var firstPass = File.ReadAllText(Path.Combine(_tmp, "example.com.yaml")); + + // Run again + await app.RunAsync(["fmt", _tmp]); + + var secondPass = File.ReadAllText(Path.Combine(_tmp, "example.com.yaml")); + secondPass.ShouldBe(firstPass, "fmt should be idempotent"); + } + + [Fact] + public async Task Fmt_Check_Returns1_WhenFilesNeedFormatting() + { + WriteZone("example.com.yaml", """ + # Zone: example.com. + # Imported by dns-sync on 2026-01-01 + + '': + - + type: TXT + ttl: 600 + value: hello + - + type: A + ttl: 600 + value: 1.2.3.4 + """.Replace(" ", "")); + + var app = BuildApp(); + var result = await app.RunAsync(["fmt", _tmp, "--check"]); + + result.ShouldBe(1); + + // File should NOT have been modified + var content = File.ReadAllText(Path.Combine(_tmp, "example.com.yaml")); + content.ShouldContain("type: TXT\n ttl: 600\n value: hello\n -\n type: A"); + } + + [Fact] + public async Task Fmt_Check_Returns0_WhenAlreadyFormatted() + { + // Write a pre-formatted file (A before TXT, apex first) + WriteZone("example.com.yaml", """ + # Zone: example.com. + # Imported by dns-sync on 2026-01-01 + + '': + - + type: A + ttl: 600 + value: 1.2.3.4 + - + type: TXT + ttl: 600 + value: hello + """.Replace(" ", "")); + + var app = BuildApp(); + // First format it to get canonical output + await app.RunAsync(["fmt", _tmp]); + + // Now check should pass + var result = await app.RunAsync(["fmt", _tmp, "--check"]); + result.ShouldBe(0); + } + + [Fact] + public async Task Fmt_NonexistentDirectory_Returns1() + { + var app = BuildApp(); + var result = await app.RunAsync(["fmt", "/nonexistent/path"]); + result.ShouldBe(1); + } + + [Fact] + public async Task Fmt_EmptyDirectory_Returns0() + { + var emptyDir = Path.Combine(_tmp, "empty"); + Directory.CreateDirectory(emptyDir); + + var app = BuildApp(); + var result = await app.RunAsync(["fmt", emptyDir]); + result.ShouldBe(0); + } + + [Fact] + public async Task Fmt_DoesNotModifyAlreadySortedFile() + { + // NS → A → CNAME → MX → TXT is already semantic order + WriteZone("example.com.yaml", """ + # Zone: example.com. + # Imported by dns-sync on 2026-01-01 + + '': + - + type: NS + ttl: 3600 + values: + - ns1.example.com. + - + type: A + ttl: 600 + value: 1.2.3.4 + - + type: MX + ttl: 3600 + values: + - preference: 10 + exchange: mail.example.com. + - + type: TXT + ttl: 600 + value: hello + """.Replace(" ", "")); + + var app = BuildApp(); + // Format once to normalize header + await app.RunAsync(["fmt", _tmp]); + var formatted = File.ReadAllText(Path.Combine(_tmp, "example.com.yaml")); + + // Second run should not change it + await app.RunAsync(["fmt", _tmp]); + var secondRun = File.ReadAllText(Path.Combine(_tmp, "example.com.yaml")); + + secondRun.ShouldBe(formatted); + } + + [Fact] + public async Task Fmt_FullSemanticOrder_NS_A_AAAA_CNAME_MX_TXT_SRV_CAA() + { + WriteZone("example.com.yaml", """ + # Zone: example.com. + # Imported by dns-sync on 2026-01-01 + + '': + - + type: CAA + ttl: 3600 + values: + - flags: 0 + tag: issue + value: "letsencrypt.org" + - + type: TXT + ttl: 600 + value: hello + - + type: MX + ttl: 3600 + values: + - preference: 10 + exchange: mail.example.com. + - + type: A + ttl: 600 + value: 1.2.3.4 + - + type: NS + ttl: 3600 + values: + - ns1.example.com. + """.Replace(" ", "")); + + var app = BuildApp(); + await app.RunAsync(["fmt", _tmp]); + + var output = File.ReadAllText(Path.Combine(_tmp, "example.com.yaml")); + var nsPos = output.IndexOf("type: NS", StringComparison.Ordinal); + var aPos = output.IndexOf("type: A", StringComparison.Ordinal); + var mxPos = output.IndexOf("type: MX", StringComparison.Ordinal); + var txtPos = output.IndexOf("type: TXT", StringComparison.Ordinal); + var caaPos = output.IndexOf("type: CAA", StringComparison.Ordinal); + + nsPos.ShouldBeLessThan(aPos); + aPos.ShouldBeLessThan(mxPos); + mxPos.ShouldBeLessThan(txtPos); + txtPos.ShouldBeLessThan(caaPos); + } + + [Fact] + public async Task Fmt_MultipleFiles_FormatsAll() + { + WriteZone("a.com.yaml", """ + # Zone: a.com. + # Imported by dns-sync on 2026-01-01 + + '': + - + type: TXT + ttl: 600 + value: first + - + type: A + ttl: 600 + value: 1.1.1.1 + """.Replace(" ", "")); + + WriteZone("b.com.yaml", """ + # Zone: b.com. + # Imported by dns-sync on 2026-01-01 + + '': + - + type: TXT + ttl: 600 + value: second + - + type: A + ttl: 600 + value: 2.2.2.2 + """.Replace(" ", "")); + + var app = BuildApp(); + var result = await app.RunAsync(["fmt", _tmp]); + + result.ShouldBe(0); + + // Both should have A before TXT now + foreach (var file in new[] { "a.com.yaml", "b.com.yaml" }) + { + var content = File.ReadAllText(Path.Combine(_tmp, file)); + var aPos = content.IndexOf("type: A", StringComparison.Ordinal); + var txtPos = content.IndexOf("type: TXT", StringComparison.Ordinal); + aPos.ShouldBeLessThan(txtPos, $"{file}: A should appear before TXT"); + } + } +}