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
2 changes: 1 addition & 1 deletion docs/wiki
Submodule wiki updated from 842634 to e73f5b
8 changes: 5 additions & 3 deletions src/DnsSync/Commands/ApplyCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

namespace DnsSync.Commands;

public class ApplyCommand(ILoggerFactory loggerFactory) : AsyncCommand<ApplySettings>
public class ApplyCommand(ILoggerFactory loggerFactory, IZoneResolver zoneResolver) : AsyncCommand<ApplySettings>
{
protected override async Task<int> ExecuteAsync(CommandContext context, ApplySettings settings, CancellationToken cancellationToken)
{
Expand All @@ -28,10 +28,12 @@ protected override async Task<int> ExecuteAsync(CommandContext context, ApplySet
if (settings.FromPlan is not null)
return await ApplyFromPlanAsync(settings, config, cancellationToken);

var zonesToProcess = config.Zones.AsEnumerable();
var allZones = await zoneResolver.ResolveAsync(config, cancellationToken);

var zonesToProcess = allZones.AsEnumerable();
if (!string.IsNullOrWhiteSpace(settings.Zone))
{
if (!config.Zones.ContainsKey(settings.Zone))
if (!allZones.ContainsKey(settings.Zone))
{
AnsiConsole.MarkupLine($"[red]✗[/] Zone '{Markup.Escape(settings.Zone)}' not found in config.");
return 1;
Expand Down
4 changes: 3 additions & 1 deletion src/DnsSync/Commands/CommandHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ public static DnsSyncConfig LoadAndValidateConfig(BaseSettings settings)
}

var zoneCount = config.Zones.Count;
var groupCount = config.ZoneGroups.Count;
var providerCount = config.Providers.Count;
var groupPart = groupCount > 0 ? $", [bold]{groupCount}[/] zone group(s)" : "";
AnsiConsole.MarkupLine(
$"[green]✓[/] Config valid ([bold]{zoneCount}[/] zone(s), [bold]{providerCount}[/] provider(s))");
$"[green]✓[/] Config valid ([bold]{zoneCount}[/] zone(s){groupPart}, [bold]{providerCount}[/] provider(s))");

return config;
}
Expand Down
5 changes: 3 additions & 2 deletions src/DnsSync/Commands/DriftCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

namespace DnsSync.Commands;

public class DriftCommand(ILoggerFactory loggerFactory) : AsyncCommand<DriftSettings>
public class DriftCommand(ILoggerFactory loggerFactory, IZoneResolver zoneResolver) : AsyncCommand<DriftSettings>
{
protected override async Task<int> ExecuteAsync(
CommandContext context, DriftSettings settings, CancellationToken cancellationToken)
Expand All @@ -29,7 +29,8 @@ protected override async Task<int> ExecuteAsync(
var hasErrors = false;
var jsonZones = new List<object>();

foreach (var (zoneName, zoneConfig) in config.Zones)
var zones = await zoneResolver.ResolveAsync(config, cancellationToken);
foreach (var (zoneName, zoneConfig) in zones)
{
var sourceProvider = ProviderFactory.Create(
zoneConfig.Source, config.Providers[zoneConfig.Source], loggerFactory);
Expand Down
8 changes: 5 additions & 3 deletions src/DnsSync/Commands/PlanCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

namespace DnsSync.Commands;

public class PlanCommand(ILoggerFactory loggerFactory) : AsyncCommand<PlanSettings>
public class PlanCommand(ILoggerFactory loggerFactory, IZoneResolver zoneResolver) : AsyncCommand<PlanSettings>
{
protected override async Task<int> ExecuteAsync(CommandContext context, PlanSettings settings, CancellationToken cancellationToken)
{
Expand All @@ -22,10 +22,12 @@ protected override async Task<int> ExecuteAsync(CommandContext context, PlanSett
{
var config = CommandHelpers.LoadAndValidateConfig(settings);

var zonesToProcess = config.Zones.AsEnumerable();
var allZones = await zoneResolver.ResolveAsync(config, cancellationToken);

var zonesToProcess = allZones.AsEnumerable();
if (!string.IsNullOrWhiteSpace(settings.Zone))
{
if (!config.Zones.ContainsKey(settings.Zone))
if (!allZones.ContainsKey(settings.Zone))
{
AnsiConsole.MarkupLine($"[red]✗[/] Zone '{Markup.Escape(settings.Zone)}' not found in config.");
return 1;
Expand Down
6 changes: 4 additions & 2 deletions src/DnsSync/Commands/ValidateCommand.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using DnsSync.Core;
using DnsSync.Providers;
using DnsSync.Providers.Yaml;
using DnsSync.Validation;
Expand All @@ -6,7 +7,7 @@

namespace DnsSync.Commands;

public class ValidateCommand : AsyncCommand<BaseSettings>
public class ValidateCommand(IZoneResolver zoneResolver) : AsyncCommand<BaseSettings>
{
protected override async Task<int> ExecuteAsync(CommandContext context, BaseSettings settings, CancellationToken cancellationToken)
{
Expand All @@ -16,7 +17,8 @@ protected override async Task<int> ExecuteAsync(CommandContext context, BaseSett

// Validate each zone file via YamlProvider
var hasErrors = false;
foreach (var (zoneName, zoneConfig) in config.Zones)
var zones = await zoneResolver.ResolveAsync(config, cancellationToken);
foreach (var (zoneName, zoneConfig) in zones)
{
var sourceProvider = config.Providers[zoneConfig.Source];
if (!string.Equals(sourceProvider.Type, "yaml", StringComparison.OrdinalIgnoreCase))
Expand Down
27 changes: 25 additions & 2 deletions src/DnsSync/Config/ConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ public static List<string> ValidateStructure(DnsSyncConfig config)
if (config.Providers.Count == 0)
errors.Add("No providers defined in config.");

if (config.Zones.Count == 0)
errors.Add("No zones defined in config.");
if (config.Zones.Count == 0 && config.ZoneGroups.Count == 0)
errors.Add("No zones or zone_groups defined in config.");

foreach (var (name, provider) in config.Providers)
{
Expand Down Expand Up @@ -139,6 +139,29 @@ public static List<string> ValidateStructure(DnsSyncConfig config)
}
}

foreach (var (groupName, group) in config.ZoneGroups)
{
if (string.IsNullOrWhiteSpace(group.Source))
errors.Add($"zone_groups.{groupName}: missing 'source'.");
else if (!config.Providers.ContainsKey(group.Source))
errors.Add($"zone_groups.{groupName}: source '{group.Source}' not found in providers.");

if (group.Targets.Count == 0)
errors.Add($"zone_groups.{groupName}: has no target providers.");

foreach (var target in group.Targets)
{
if (!config.Providers.ContainsKey(target))
errors.Add($"zone_groups.{groupName}: target '{target}' not found in providers.");

if (target == group.Source)
errors.Add($"zone_groups.{groupName}: uses '{target}' as both source and target.");

if (config.Providers.TryGetValue(target, out var targetConfig) && targetConfig.ReadOnly)
errors.Add($"zone_groups.{groupName}: uses read-only provider '{target}' as a target.");
}
}

return errors;
}
}
20 changes: 20 additions & 0 deletions src/DnsSync/Config/DnsSyncConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public class DnsSyncConfig

[YamlMember(Alias = "zones")]
public Dictionary<string, ZoneConfig> Zones { get; set; } = new();

[YamlMember(Alias = "zone_groups")]
public Dictionary<string, ZoneGroupConfig> ZoneGroups { get; set; } = new();
}

public class ProviderConfig
Expand Down Expand Up @@ -73,3 +76,20 @@ public class ZoneConfig
[YamlMember(Alias = "targets")]
public List<string> Targets { get; set; } = new();
}

public class ZoneGroupConfig
{
[YamlMember(Alias = "source")]
public string Source { get; set; } = string.Empty;

[YamlMember(Alias = "targets")]
public List<string> Targets { get; set; } = new();

/// <summary>Optional regex. Only zones whose FQDN matches are included.</summary>
[YamlMember(Alias = "include_pattern")]
public string? IncludePattern { get; set; }

/// <summary>Optional regex. Zones whose FQDN matches are excluded (applied after include_pattern).</summary>
[YamlMember(Alias = "exclude_pattern")]
public string? ExcludePattern { get; set; }
}
14 changes: 14 additions & 0 deletions src/DnsSync/Core/IZoneResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using DnsSync.Config;

namespace DnsSync.Core;

public interface IZoneResolver
{
/// <summary>
/// Returns the merged set of zones to process: explicit <c>zones:</c> entries take
/// precedence over zones discovered via <c>zone_groups:</c>. Emits a warning for any
/// zone_group zone that is overridden by an explicit zone entry.
/// </summary>
Task<IReadOnlyDictionary<string, ZoneConfig>> ResolveAsync(
DnsSyncConfig config, CancellationToken cancellationToken);
}
6 changes: 3 additions & 3 deletions src/DnsSync/Core/ZoneDiff.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ public static DnsPlan Diff(DnsZone source, DnsZone target, bool includeApexNs =
.Where(r => !ShouldIgnore(r, source.Name, includeApexNs))
.ToList();

// Group by (name, type) — DNS record sets
// Group by (name, type) — DNS record sets. Names are case-insensitive per RFC 1035.
var sourceByKey = sourceRecords
.GroupBy(r => (r.Name, r.Type))
.GroupBy(r => (Name: r.Name.ToLowerInvariant(), Type: r.Type.ToUpperInvariant()))
.ToDictionary(g => g.Key, g => g.ToList());

var targetByKey = targetRecords
.GroupBy(r => (r.Name, r.Type))
.GroupBy(r => (Name: r.Name.ToLowerInvariant(), Type: r.Type.ToUpperInvariant()))
.ToDictionary(g => g.Key, g => g.ToList());

// Records in source but not in target → Create
Expand Down
71 changes: 71 additions & 0 deletions src/DnsSync/Core/ZoneResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.Text.RegularExpressions;
using DnsSync.Config;
using DnsSync.Providers;
using Microsoft.Extensions.Logging;
using Spectre.Console;

namespace DnsSync.Core;

public class ZoneResolver(ILoggerFactory loggerFactory) : IZoneResolver
{
public async Task<IReadOnlyDictionary<string, ZoneConfig>> ResolveAsync(
DnsSyncConfig config, CancellationToken cancellationToken)
{
// Start with explicit zones — they always win.
var resolved = new Dictionary<string, ZoneConfig>(
config.Zones, StringComparer.OrdinalIgnoreCase);

if (config.ZoneGroups.Count == 0)
return resolved;

foreach (var (groupName, group) in config.ZoneGroups)
{
var provider = ProviderFactory.Create(
group.Source, config.Providers[group.Source], loggerFactory);

IReadOnlyList<string> discovered;
try
{
discovered = await provider.GetZonesAsync(cancellationToken);
}
catch (Exception ex)
{
AnsiConsole.MarkupLine(
$"[yellow]⚠[/] Zone group '{Markup.Escape(groupName)}': " +
$"failed to discover zones from '{Markup.Escape(group.Source)}': {Markup.Escape(ex.Message)}");
continue;
}

Regex? includeRx = group.IncludePattern is not null
? new Regex(group.IncludePattern, RegexOptions.IgnoreCase | RegexOptions.Compiled)
: null;
Regex? excludeRx = group.ExcludePattern is not null
? new Regex(group.ExcludePattern, RegexOptions.IgnoreCase | RegexOptions.Compiled)
: null;

foreach (var zoneName in discovered)
{
if (includeRx is not null && !includeRx.IsMatch(zoneName))
continue;
if (excludeRx is not null && excludeRx.IsMatch(zoneName))
continue;

if (resolved.ContainsKey(zoneName))
{
AnsiConsole.MarkupLine(
$"[yellow]~[/] Zone '{Markup.Escape(zoneName)}' from group " +
$"'{Markup.Escape(groupName)}' overridden by explicit zones: entry");
continue;
}

resolved[zoneName] = new ZoneConfig
{
Source = group.Source,
Targets = group.Targets,
};
}
}

return resolved;
}
}
3 changes: 3 additions & 0 deletions src/DnsSync/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Reflection;
using DnsSync.Commands;
using DnsSync.Config;
using DnsSync.Core;
using DnsSync.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -98,6 +99,8 @@
.ClearProviders()
.AddSerilog(dispose: true));

services.AddSingleton<IZoneResolver, ZoneResolver>();

var registrar = new TypeRegistrar(services);
var app = new CommandApp(registrar);

Expand Down
Loading
Loading