From f5bbe7d6082448882c96ceefbad502a3893b4466 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Sat, 31 Jan 2026 09:49:10 +1000 Subject: [PATCH] Refactor: ServiceCollectionExtensions data-driven registration; LeasesCache + FSW; test harness scripts --- .gitignore | 3 + Dockerfile.dnsmasq | 2 +- docker-compose.test.yml | 11 +- scripts/prepare-test-mount.sh | 120 +++++++++++++++++ .../Components/Pages/Leases.razor | 2 +- src/DnsmasqWebUI/DnsmasqWebUI.csproj | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 32 +++-- src/DnsmasqWebUI/Parsers/DhcpHostParser.cs | 46 ++++--- src/DnsmasqWebUI/Parsers/HostsParser.cs | 32 +++-- src/DnsmasqWebUI/Parsers/LeasesParser.cs | 24 ++-- .../Abstractions/IApplicationSingleton.cs | 9 ++ .../Services/Abstractions/ILeasesCache.cs | 11 ++ src/DnsmasqWebUI/Services/LeasesCache.cs | 124 ++++++++++++++++++ .../Services/LeasesFileService.cs | 44 +------ testdata/README.md | 13 +- 15 files changed, 376 insertions(+), 99 deletions(-) create mode 100755 scripts/prepare-test-mount.sh create mode 100644 src/DnsmasqWebUI/Services/Abstractions/IApplicationSingleton.cs create mode 100644 src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs create mode 100644 src/DnsmasqWebUI/Services/LeasesCache.cs diff --git a/.gitignore b/.gitignore index 9c379d8..484e56c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ artifacts/ # Data (dev files - optional: track sample or ignore) # Data/ +# Test harness: copy of testdata used as container mount (do not edit repo testdata) +testdata-mount/ + # IDE .idea/ .vscode/ diff --git a/Dockerfile.dnsmasq b/Dockerfile.dnsmasq index be057b1..3bd1632 100644 --- a/Dockerfile.dnsmasq +++ b/Dockerfile.dnsmasq @@ -23,7 +23,7 @@ ENV ASPNETCORE_URLS=http://+:8080 COPY --from=publish /app/publish . FROM app AS final -RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq \ +RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \ && rm -rf /var/lib/apt/lists/* COPY entrypoint.sh . RUN chmod +x entrypoint.sh diff --git a/docker-compose.test.yml b/docker-compose.test.yml index ef8fea8..0818242 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,8 +1,9 @@ # Run the app with real dnsmasq and a DHCP client so hosts/leases are real. -# Build from repo root: docker compose -f docker-compose.test.yml up --build +# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR] +# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount). # # app builds from Dockerfile.dnsmasq (app + dnsmasq in one container; app is main process). -# ReloadCommand/StatusCommand work because dnsmasq runs in the same container. +# ReloadCommand/StatusCommand use pkill/pgrep (procps in image). cap_add NET_ADMIN for dnsmasq. # # Networks: testnet 172.28.0.0/16 so dnsmasq can hand out 172.28.0.10–50. services: @@ -12,15 +13,17 @@ services: dockerfile: Dockerfile.dnsmasq ports: - "8080:8080" + cap_add: + - NET_ADMIN environment: DNSMASQ_CONF: /data/dnsmasq-test.conf Dnsmasq__HostsPath: /data/hosts Dnsmasq__ConfigPath: /data/dnsmasq.d/dhcp.conf Dnsmasq__LeasesPath: /data/leases - Dnsmasq__ReloadCommand: "killall -HUP dnsmasq" + Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq" Dnsmasq__StatusCommand: "pgrep -x dnsmasq" volumes: - - ./testdata:/data + - ${TESTDATA_MOUNT:-./testdata-mount}:/data networks: testnet: ipv4_address: 172.28.0.2 diff --git a/scripts/prepare-test-mount.sh b/scripts/prepare-test-mount.sh new file mode 100755 index 0000000..9152d3a --- /dev/null +++ b/scripts/prepare-test-mount.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env sh +# Prepare testdata-mount from testdata (or --source) and optionally run the Docker test harness. +# testdata/leases is excluded so dnsmasq creates the real leases file in the container. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +COMPOSE_FILE="docker-compose.test.yml" + +SOURCE_DIR="" +MOUNT_DIR="" +PREPARE_ONLY=false +NO_BUILD=false +RECREATE=false + +usage() { + echo "Usage: $0 [OPTIONS] [--]" + echo "" + echo "Copy test data into the mount directory (default: testdata -> testdata-mount)," + echo "then run 'docker compose -f $COMPOSE_FILE up' unless --prepare-only is set." + echo "" + echo "Options:" + echo " -h, --help Show this help" + echo " --source DIR Source directory to copy from (default: testdata)" + echo " --mount DIR Target mount directory (default: testdata-mount)" + echo " --prepare-only Only copy data; do not run docker compose" + echo " --no-build Run 'docker compose up' without --build (use existing images)" + echo " --recreate Pass --force-recreate to docker compose up" + echo "" + echo "Examples:" + echo " $0 # Prepare from testdata, then up --build" + echo " $0 --no-build # Prepare, then up without rebuilding" + echo " $0 --prepare-only # Only sync testdata -> testdata-mount" + echo " $0 --source myfixtures --mount mymount --prepare-only" +} + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --source) + shift + [ $# -gt 0 ] || { echo "Error: --source requires DIR" >&2; exit 1; } + SOURCE_DIR="$1" + shift + ;; + --mount) + shift + [ $# -gt 0 ] || { echo "Error: --mount requires DIR" >&2; exit 1; } + MOUNT_DIR="$1" + shift + ;; + --prepare-only) + PREPARE_ONLY=true + shift + ;; + --no-build) + NO_BUILD=true + shift + ;; + --recreate) + RECREATE=true + shift + ;; + --) + shift + break + ;; + *) + echo "Error: unknown option $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +cd "$REPO_ROOT" + +: "${SOURCE_DIR:=testdata}" +: "${MOUNT_DIR:=testdata-mount}" + +if [ ! -d "$SOURCE_DIR" ]; then + echo "Error: source directory '$SOURCE_DIR' does not exist" >&2 + exit 1 +fi + +mkdir -p "$MOUNT_DIR" + +if command -v rsync >/dev/null 2>&1; then + rsync -a --exclude=leases "$SOURCE_DIR/" "$MOUNT_DIR/" +else + find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true + cp -r "$SOURCE_DIR/." "$MOUNT_DIR/" + rm -f "$MOUNT_DIR/leases" +fi + +echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR, leases excluded)." + +if [ "$PREPARE_ONLY" = true ]; then + echo "Run manually: docker compose -f $COMPOSE_FILE up --build" + exit 0 +fi + +# Compose uses TESTDATA_MOUNT for the data volume (default: ./testdata-mount) +export TESTDATA_MOUNT="./$MOUNT_DIR" + +COMPOSE_CMD="docker compose -f $COMPOSE_FILE up -d" +if [ "$NO_BUILD" = true ]; then + COMPOSE_CMD="$COMPOSE_CMD" +else + COMPOSE_CMD="$COMPOSE_CMD --build" +fi +if [ "$RECREATE" = true ]; then + COMPOSE_CMD="$COMPOSE_CMD --force-recreate" +fi + +echo "Running: $COMPOSE_CMD" +exec $COMPOSE_CMD diff --git a/src/DnsmasqWebUI/Components/Pages/Leases.razor b/src/DnsmasqWebUI/Components/Pages/Leases.razor index d0c568d..0be4e08 100644 --- a/src/DnsmasqWebUI/Components/Pages/Leases.razor +++ b/src/DnsmasqWebUI/Components/Pages/Leases.razor @@ -47,7 +47,7 @@ else } -

Path: @_status.leasesPath (read-only). Data is read from disk on each request; use Refresh to refetch.

+

Path: @_status.leasesPath (read-only). Cached and refreshed when the file changes; use Refresh to refetch now.

} } diff --git a/src/DnsmasqWebUI/DnsmasqWebUI.csproj b/src/DnsmasqWebUI/DnsmasqWebUI.csproj index 4b38974..bf03929 100644 --- a/src/DnsmasqWebUI/DnsmasqWebUI.csproj +++ b/src/DnsmasqWebUI/DnsmasqWebUI.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs index 0643985..902aa14 100644 --- a/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs +++ b/src/DnsmasqWebUI/Extensions/ServiceCollectionExtensions.cs @@ -6,24 +6,40 @@ namespace DnsmasqWebUI.Extensions; public static class ServiceCollectionExtensions { + delegate void RegisterService(IServiceCollection s, Type iface, Type impl); + + static readonly (Type MarkerInterface, RegisterService Register)[] ApplicationRegistrations = + [ + (typeof(IApplicationScopedService), (s, i, impl) => s.AddScoped(i, impl)), + (typeof(IApplicationSingleton), (s, i, impl) => s.AddSingleton(i, impl)), + ]; + /// - /// Scans the assembly for types implementing - /// and registers each interface → implementation as scoped. + /// Scans the assembly for types implementing application marker interfaces + /// (, , etc.) + /// and registers each interface → implementation with the configured lifetime. /// Skips open generics; requires exactly one public implementation per interface. /// public static IServiceCollection AddApplicationServices(this IServiceCollection services) { + foreach (var (markerInterface, register) in ApplicationRegistrations) + ScanAndRegister(services, markerInterface, register); + return services; + } + + static void ScanAndRegister( + IServiceCollection services, + Type markerInterface, + RegisterService register) + { var assembly = Assembly.GetExecutingAssembly(); - var marker = typeof(IApplicationScopedService); - // Only closed (non-generic) interfaces; open generics need typeof(IRepo<>, Repo<>) and are not handled here. var serviceInterfaces = assembly.GetTypes() - .Where(t => t.IsInterface && t.IsPublic && t != marker && !t.IsGenericTypeDefinition && marker.IsAssignableFrom(t)) + .Where(t => t.IsInterface && t.IsPublic && t != markerInterface && !t.IsGenericTypeDefinition && markerInterface.IsAssignableFrom(t)) .ToList(); foreach (var iface in serviceInterfaces) { - // Public, concrete, non-abstract, closed (no open generic classes). var implementations = assembly.GetTypes() .Where(t => t.IsClass && t.IsPublic && !t.IsAbstract && !t.IsGenericTypeDefinition && iface.IsAssignableFrom(t)) .ToList(); @@ -35,9 +51,7 @@ public static class ServiceCollectionExtensions $"Multiple implementations for {iface.FullName}: {string.Join(", ", implementations.Select(x => x.FullName))}. " + "Register one explicitly or exclude the others from the scan."); - services.AddScoped(iface, implementations[0]); + register(services, iface, implementations[0]); } - - return services; } } diff --git a/src/DnsmasqWebUI/Parsers/DhcpHostParser.cs b/src/DnsmasqWebUI/Parsers/DhcpHostParser.cs index e89fb33..0935646 100644 --- a/src/DnsmasqWebUI/Parsers/DhcpHostParser.cs +++ b/src/DnsmasqWebUI/Parsers/DhcpHostParser.cs @@ -1,6 +1,8 @@ using System.Text.RegularExpressions; using DnsmasqWebUI.Models; -using Sprache; +using Superpower; +using Superpower.Model; +using Superpower.Parsers; namespace DnsmasqWebUI.Parsers; @@ -13,31 +15,35 @@ public static class DhcpHostParser { private static readonly Regex HostnameRegex = new(@"^[a-zA-Z][-_a-zA-Z0-9]*$", RegexOptions.Compiled); - // Optional ## or # at start - private static readonly Parser<(bool isComment, bool isDeleted)> Prefix = - Parse.Char('#').Repeat(1, 2).Optional().Select(o => - { - if (!o.IsDefined) return (false, false); - var s = string.Concat(o.Get()); - return (true, s.Length == 2); - }); + // Allow optional whitespace around a parser + private static TextParser Token(TextParser parser) => + Character.WhiteSpace.Many().IgnoreThen(parser).Then(x => + Character.WhiteSpace.Many().IgnoreThen(Parse.Return(x))); + + // Optional ## or # at start (Try so that single # backtracks and we can match one #) + private static readonly TextParser<(bool isComment, bool isDeleted)> Prefix = + Character.EqualTo('#').Repeat(2).Select(_ => (true, true)).Try() + .Or(Character.EqualTo('#').Select(_ => (true, false))) + .OptionalOrDefault((false, false)); // Literal "dhcp-host=" (consumed, value discarded) - private static readonly Parser> DhcpHostTag = - Parse.String("dhcp-host=").Token(); + private static readonly TextParser DhcpHostTag = + Token(Span.EqualTo("dhcp-host=")).Value(Unit.Value); // One field: no comma, no # (stops at next comma or trailing comment) - private static readonly Parser Field = - Parse.AnyChar.Where(c => c != ',' && c != '#').AtLeastOnce().Text().Token(); + private static readonly TextParser Field = + Character.Matching(c => c != ',' && c != '#', "field").AtLeastOnce().Text().Then(s => + Character.WhiteSpace.Many().IgnoreThen(Parse.Return(s))); - // Comma-delimited fields (Sprache DelimitedBy), then optional trailing # comment - private static readonly Parser<(List fields, string? comment)> FieldsAndComment = - from fields in Field.DelimitedBy(Parse.Char(',').Token()).Select(l => l.ToList()) - from comment in Parse.Char('#').Token().Then(_ => Parse.AnyChar.Many().Text()).Optional() - select (fields, comment.IsDefined ? comment.Get().Trim() : null); + // Comma-delimited fields, then optional # comment + private static readonly TextParser<(List fields, string? comment)> FieldsAndComment = + from fields in Field.AtLeastOnceDelimitedBy(Token(Character.EqualTo(','))) + from comment in Token(Character.EqualTo('#')).IgnoreThen(Character.AnyChar.Many().Text()) + .Select(s => (string?)s).OptionalOrDefault(null) + select (fields.ToList(), string.IsNullOrEmpty(comment) ? null : comment.Trim()); // Full line: optional ##/# prefix, "dhcp-host=", comma-separated fields, optional # comment - private static readonly Parser<(bool isComment, bool isDeleted, List fields, string? comment)> LineParser = + private static readonly TextParser<(bool isComment, bool isDeleted, List fields, string? comment)> LineParser = from prefix in Prefix from _ in DhcpHostTag from fc in FieldsAndComment @@ -52,7 +58,7 @@ public static class DhcpHostParser return null; var result = LineParser.TryParse(remain); - if (!result.WasSuccessful) + if (!result.HasValue) return null; var (isComment, isDeleted, fields, comment) = result.Value; diff --git a/src/DnsmasqWebUI/Parsers/HostsParser.cs b/src/DnsmasqWebUI/Parsers/HostsParser.cs index 240d06f..6a98ee5 100644 --- a/src/DnsmasqWebUI/Parsers/HostsParser.cs +++ b/src/DnsmasqWebUI/Parsers/HostsParser.cs @@ -1,5 +1,7 @@ using DnsmasqWebUI.Models; -using Sprache; +using Superpower; +using Superpower.Model; +using Superpower.Parsers; namespace DnsmasqWebUI.Parsers; @@ -9,26 +11,32 @@ namespace DnsmasqWebUI.Parsers; /// public static class HostsParser { + // Allow optional whitespace around a parser (Superpower has no built-in Token for text parsers) + private static TextParser Token(TextParser parser) => + Character.WhiteSpace.Many().IgnoreThen(parser).Then(x => + Character.WhiteSpace.Many().IgnoreThen(Parse.Return(x))); + // Token: non-whitespace, non-# (stops at inline comment) - private static readonly Parser Token = - Parse.AnyChar.Where(c => !char.IsWhiteSpace(c) && c != '#').AtLeastOnce().Text(); + private static readonly TextParser TokenField = + Character.Matching(c => !char.IsWhiteSpace(c) && c != '#', "token") + .AtLeastOnce().Text(); // Optional leading # (comment line); when present, rest of line is comment text, not data - private static readonly Parser OptionalComment = - Parse.Char('#').Token().Optional().Select(o => o.IsDefined); + private static readonly TextParser OptionalComment = + Token(Character.EqualTo('#')).Select(_ => true).OptionalOrDefault(false); // Content: address (first token) then one or more names (tokens). Per hosts(5): IP then canonical name [aliases...]. - private static readonly Parser<(string address, List names)> Content = - from address in Token - from _ in Parse.WhiteSpace.AtLeastOnce() - from names in Token.AtLeastOnce() + private static readonly TextParser<(string address, List names)> Content = + from address in TokenField + from _ in Span.WhiteSpace + from names in TokenField.AtLeastOnce() select (address, names.ToList()); // Full line: if starts with #, treat entire line as comment (do not parse rest as address/names); else parse Content. - private static readonly Parser<(bool isComment, string address, List names)> LineContent = + private static readonly TextParser<(bool isComment, string address, List names)> LineContent = OptionalComment.Then(hasComment => hasComment - ? Parse.AnyChar.Many().Text().Select(_ => (true, "", new List())) + ? Character.AnyChar.Many().Text().Select(_ => (true, "", new List())) : Content.Select(c => (false, c.address, c.names))); public static HostEntry? ParseLine(string line, int lineNumber) @@ -38,7 +46,7 @@ public static class HostsParser return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true }; var result = LineContent.TryParse(trimmed); - if (!result.WasSuccessful) + if (!result.HasValue) return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true }; var (isComment, address, names) = result.Value; diff --git a/src/DnsmasqWebUI/Parsers/LeasesParser.cs b/src/DnsmasqWebUI/Parsers/LeasesParser.cs index e6b7359..43946e7 100644 --- a/src/DnsmasqWebUI/Parsers/LeasesParser.cs +++ b/src/DnsmasqWebUI/Parsers/LeasesParser.cs @@ -1,5 +1,7 @@ using DnsmasqWebUI.Models; -using Sprache; +using Superpower; +using Superpower.Model; +using Superpower.Parsers; namespace DnsmasqWebUI.Parsers; @@ -20,12 +22,18 @@ namespace DnsmasqWebUI.Parsers; /// public static class LeasesParser { - // Non-whitespace token (MAC, IP, hostname, or * for unknown) - private static readonly Parser Field = - Parse.AnyChar.Where(c => !char.IsWhiteSpace(c)).AtLeastOnce().Text().Token(); + // Allow optional whitespace around a parser + private static TextParser Token(TextParser parser) => + Character.WhiteSpace.Many().IgnoreThen(parser).Then(x => + Character.WhiteSpace.Many().IgnoreThen(Parse.Return(x))); - private static readonly Parser LineParser = - (from epoch in Parse.Number.Token().Select(s => long.Parse(s)) + // Non-whitespace token (MAC, IP, hostname, or * for unknown) + private static readonly TextParser Field = + Character.Matching(c => !char.IsWhiteSpace(c), "field").AtLeastOnce().Text().Then(s => + Character.WhiteSpace.Many().IgnoreThen(Parse.Return(s))); + + private static readonly TextParser LineParser = + (from epoch in Token(Numerics.IntegerInt64) from mac in Field from address in Field from name in Field @@ -37,7 +45,7 @@ public static class LeasesParser Address = address, Name = name, ClientId = clientId - }).End(); + }).AtEnd(); public static LeaseEntry? ParseLine(string line) { @@ -46,6 +54,6 @@ public static class LeasesParser return null; var result = LineParser.TryParse(trimmed); - return result.WasSuccessful ? result.Value : null; + return result.HasValue ? result.Value : null; } } diff --git a/src/DnsmasqWebUI/Services/Abstractions/IApplicationSingleton.cs b/src/DnsmasqWebUI/Services/Abstractions/IApplicationSingleton.cs new file mode 100644 index 0000000..8c6d485 --- /dev/null +++ b/src/DnsmasqWebUI/Services/Abstractions/IApplicationSingleton.cs @@ -0,0 +1,9 @@ +namespace DnsmasqWebUI.Services.Abstractions; + +/// +/// Marker interface for application services that are registered as singleton. +/// Used by assembly scanning for service registration. +/// +public interface IApplicationSingleton +{ +} diff --git a/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs b/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs new file mode 100644 index 0000000..0075315 --- /dev/null +++ b/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs @@ -0,0 +1,11 @@ +using DnsmasqWebUI.Models; + +namespace DnsmasqWebUI.Services.Abstractions; + +/// +/// Singleton cache for the leases file; invalidates when the file changes (e.g. via file watcher). +/// +public interface ILeasesCache : IApplicationSingleton +{ + Task<(bool Available, IReadOnlyList? Entries)> GetOrRefreshAsync(CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Services/LeasesCache.cs b/src/DnsmasqWebUI/Services/LeasesCache.cs new file mode 100644 index 0000000..267c67e --- /dev/null +++ b/src/DnsmasqWebUI/Services/LeasesCache.cs @@ -0,0 +1,124 @@ +using System.Text; +using DnsmasqWebUI.Models; +using DnsmasqWebUI.Options; +using DnsmasqWebUI.Parsers; +using DnsmasqWebUI.Services.Abstractions; +using Microsoft.Extensions.Options; + +namespace DnsmasqWebUI.Services; + +/// +/// Singleton cache for the leases file with a so we re-read only when the file changes. +/// +public sealed class LeasesCache : ILeasesCache, IDisposable +{ + private readonly string? _path; + private readonly ILogger _logger; + private FileSystemWatcher? _watcher; + private readonly object _lock = new(); + private (bool Available, IReadOnlyList? Entries)? _cache; + private bool _dirty = true; + + public LeasesCache(IOptions options, ILogger logger) + { + _path = options.Value.LeasesPath; + _logger = logger; + if (string.IsNullOrEmpty(_path)) + { + _logger.LogDebug("Leases path not configured; no file watcher"); + return; + } + var dir = Path.GetDirectoryName(_path); + var fileName = Path.GetFileName(_path); + if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(fileName)) + { + _logger.LogDebug("Invalid leases path for watcher: {Path}", _path); + return; + } + try + { + if (!Directory.Exists(dir)) + { + _logger.LogDebug("Leases directory does not exist yet: {Dir}", dir); + return; + } + _watcher = new FileSystemWatcher(dir) + { + Filter = fileName, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size + }; + _watcher.Changed += OnFileChanged; + _watcher.Created += OnFileChanged; + _watcher.EnableRaisingEvents = true; + _logger.LogDebug("Watching leases file: {Path}", _path); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not create file watcher for leases: {Path}", _path); + } + } + + private void OnFileChanged(object sender, FileSystemEventArgs e) + { + lock (_lock) + { + _dirty = true; + } + } + + public (bool Available, IReadOnlyList? Entries) GetOrRefresh(CancellationToken ct = default) + { + if (string.IsNullOrEmpty(_path)) + return (false, null); + + lock (_lock) + { + if (!_dirty && _cache.HasValue) + return _cache.Value; + + if (!File.Exists(_path)) + { + _logger.LogDebug("Leases file not found: {Path}", _path); + _cache = (true, Array.Empty()); + _dirty = false; + return _cache.Value; + } + try + { + var lines = File.ReadAllLines(_path, Encoding.UTF8); + var entries = new List(); + foreach (var line in lines) + { + var entry = LeasesParser.ParseLine(line); + if (entry != null) + entries.Add(entry); + } + _cache = (true, entries); + _dirty = false; + return _cache.Value; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read leases file: {Path}", _path); + _cache = (true, null); + _dirty = false; + return _cache.Value; + } + } + } + + public async Task<(bool Available, IReadOnlyList? Entries)> GetOrRefreshAsync(CancellationToken ct = default) + { + if (string.IsNullOrEmpty(_path)) + return (false, null); + + // Run file I/O on thread pool to avoid blocking + return await Task.Run(() => GetOrRefresh(ct), ct); + } + + public void Dispose() + { + _watcher?.Dispose(); + _watcher = null; + } +} diff --git a/src/DnsmasqWebUI/Services/LeasesFileService.cs b/src/DnsmasqWebUI/Services/LeasesFileService.cs index 75b1218..d36e1aa 100644 --- a/src/DnsmasqWebUI/Services/LeasesFileService.cs +++ b/src/DnsmasqWebUI/Services/LeasesFileService.cs @@ -1,57 +1,23 @@ -using System.Text; using DnsmasqWebUI.Models; -using DnsmasqWebUI.Options; -using DnsmasqWebUI.Parsers; using DnsmasqWebUI.Services.Abstractions; -using Microsoft.Extensions.Options; namespace DnsmasqWebUI.Services; public class LeasesFileService : ILeasesFileService { - private readonly string? _path; - private readonly ILogger _logger; + private readonly ILeasesCache _cache; - public LeasesFileService(IOptions options, ILogger logger) + public LeasesFileService(ILeasesCache cache) { - _path = options.Value.LeasesPath; - _logger = logger; + _cache = cache; } public async Task> ReadAsync(CancellationToken ct = default) { - var (available, entries) = await TryReadAsync(ct); + var (_, entries) = await _cache.GetOrRefreshAsync(ct); return entries ?? Array.Empty(); } public async Task<(bool Available, IReadOnlyList? Entries)> TryReadAsync(CancellationToken ct = default) - { - if (string.IsNullOrEmpty(_path)) - { - _logger.LogDebug("Leases path not configured"); - return (false, null); - } - if (!File.Exists(_path)) - { - _logger.LogDebug("Leases file not found: {Path}", _path); - return (true, Array.Empty()); - } - try - { - var lines = await File.ReadAllLinesAsync(_path, Encoding.UTF8, ct); - var entries = new List(); - foreach (var line in lines) - { - var entry = LeasesParser.ParseLine(line); - if (entry != null) - entries.Add(entry); - } - return (true, entries); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to read leases file: {Path}", _path); - return (true, null); - } - } + => await _cache.GetOrRefreshAsync(ct); } diff --git a/testdata/README.md b/testdata/README.md index c4314b7..db6f115 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -1,10 +1,15 @@ -# Test data for docker-compose.test.yml +# Test data + +Used in two ways: + +1. **Unit tests** – Parsers and services read from `testdata/` (e.g. `testdata/leases` for lease-format tests). +2. **Docker test harness** – Run `scripts/prepare-test-mount.sh` to copy this into `testdata-mount/` (gitignored). Compose mounts `testdata-mount` as `/data`. **leases** is excluded from the copy so dnsmasq creates and owns the real leases file in the harness. + +Files: - **hosts** – Shared by app and dnsmasq (read/write by app, `addn-hosts` for dnsmasq). - **dnsmasq.conf** – Sample main config (reference only; not used by the test containers). - **dnsmasq-test.conf** – Config for dnsmasq when run inside the app container (DHCP-only, paths under `/data`). - **dnsmasq.d/dhcp.conf** – DHCP static hosts; app edits this, dnsmasq loads it via `conf-dir`. - **dnsmasq.d/01-other.conf** – Other snippet (not managed by app). -- **leases** – Empty at start. dnsmasq creates and populates it when the dhcp-client container gets a lease. App reads it (read-only). - -All of this is bind-mounted as `/data` in the **app** container; the app runs as the main process and dnsmasq runs in the background in the same container. +- **leases** – For **unit tests only**. In the Docker harness, dnsmasq creates `/data/leases`; the app watches it and shows live leases.