Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/DnsSync/Commands/FmtCommand.cs
Original file line number Diff line number Diff line change
@@ -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<FmtSettings>
{
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<DnsRecord> 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;
}
}
16 changes: 16 additions & 0 deletions src/DnsSync/Commands/FmtSettings.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
5 changes: 5 additions & 0 deletions src/DnsSync/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FmtCommand>("fmt")
.WithDescription("Reformat zone YAML files to canonical sorted style")
.WithExample(["fmt"])
.WithExample(["fmt", "./zones", "--check"]);

config.AddCommand<DriftCommand>("drift")
.WithDescription("Detect DNS record drift from desired state without applying changes")
.WithExample(["drift", "--config", "config.yaml"])
Expand Down
42 changes: 39 additions & 3 deletions src/DnsSync/Providers/Yaml/ZoneYamlSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,37 @@ namespace DnsSync.Providers.Yaml;
/// </summary>
public static class ZoneYamlSerializer
{
private static readonly Dictionary<string, int> 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;

/// <summary>
/// Returns a sorted copy of the record with MX values ordered by preference ascending.
/// Non-MX records are returned as-is.
/// </summary>
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();
Expand All @@ -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)
Expand Down
Loading
Loading