Skip to content

Commit 3a28670

Browse files
windischbclaude
andcommitted
feat: add opt-in symlink target tracking to ResilientFileSystemMonitor
Adds WithSymlinkTargetTracking() (default off) so the monitor detects when a watched symlink's resolved target swaps - enabling hot-reload of Kubernetes ConfigMap/Secret volume mounts, which update via an atomic ..data symlink swap rather than by rewriting the watched file. Detection folds the canonicalized final target into the change fingerprint on both the audit and polling-fallback paths and surfaces a Changed event on the user-visible path. Resolves the target's parent directory in addition to the link chain so it works on Linux/containers (where File.ResolveLinkTarget does not canonicalize intermediate directory symlinks) as well as Windows. Verified on Windows and a real Linux container (143/0 on both). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 00debe2 commit 3a28670

4 files changed

Lines changed: 394 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.3.0] - 2026-06-02
11+
12+
### Added
13+
- **Symlink Target Tracking** (opt-in) for `ResilientFileSystemMonitor`
14+
- `.WithSymlinkTargetTracking()` — follows a watched symlink to its resolved final target and folds
15+
that target into the change fingerprint, so an atomic symlink-target swap is detected and surfaced
16+
as a `Changed` event on the user-visible (symlinked) path
17+
- Enables hot-reload of Kubernetes **ConfigMap/Secret** volume mounts, which update content by an
18+
atomic swap of the `..data` symlink rather than by rewriting the watched file
19+
- **Off by default** — existing behavior is unchanged (symlinks/reparse points remain skipped); when
20+
enabled, reparse-point entries are indexed and their resolved target is tracked
21+
- Detected on both the audit (watching state) and polling-fallback paths; only the final target is
22+
resolved (no recursion into it), so loop-safety is preserved and ordinary non-symlinked files incur
23+
no extra cost
24+
- Resolves the target's parent directory in addition to the link chain, so detection works
25+
consistently on Linux/containers (where `File.ResolveLinkTarget` does not canonicalize intermediate
26+
directory symlinks) as well as Windows
27+
1028
## [2.2.0] - 2025-11-15
1129

1230
### Added
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
using Cocoar.FileSystem.Tests.TestUtilities;
2+
3+
namespace Cocoar.FileSystem.Tests;
4+
5+
/// <summary>
6+
/// Tests for the opt-in <c>WithSymlinkTargetTracking()</c> capability.
7+
///
8+
/// Scenario: a Kubernetes ConfigMap/Secret volume mounts each key as a symlink whose content is
9+
/// updated by an atomic swap of a sibling directory symlink (kubelet uses "..data" / "..&lt;timestamp&gt;").
10+
/// The user-visible file (e.g. config.json) is never modified — only the intermediate link's target
11+
/// changes — so the monitor's metadata-only fingerprint misses the update on the snapshot/audit path.
12+
///
13+
/// With tracking on, the monitor folds the file's fully-resolved final target into the fingerprint,
14+
/// so a target swap is detected and surfaced as a Changed event on the user-visible path.
15+
///
16+
/// These tests create real symlinks and are skipped on hosts that cannot create them (e.g. Windows
17+
/// without Developer Mode / elevation).
18+
/// </summary>
19+
public sealed class SymlinkTargetTrackingTests : IDisposable
20+
{
21+
private static readonly bool SymlinksSupported = CheckSymlinkSupport();
22+
23+
private readonly string _testRoot;
24+
private readonly List<IDisposable> _disposables = new();
25+
26+
public SymlinkTargetTrackingTests()
27+
{
28+
_testRoot = Path.Combine(Path.GetTempPath(), $"SymlinkTracking_Tests_{Guid.NewGuid():N}");
29+
Directory.CreateDirectory(_testRoot);
30+
}
31+
32+
public void Dispose()
33+
{
34+
foreach (var disposable in _disposables)
35+
{
36+
try { disposable.Dispose(); } catch { /* ignore */ }
37+
}
38+
_disposables.Clear();
39+
40+
try
41+
{
42+
if (Directory.Exists(_testRoot))
43+
Directory.Delete(_testRoot, recursive: true);
44+
}
45+
catch
46+
{
47+
// Best effort cleanup
48+
}
49+
}
50+
51+
[Fact]
52+
public async Task ConfigMapStyleSwap_WithTracking_EmitsChangedForUserVisiblePath()
53+
{
54+
if (!EnsureSymlinkCapability())
55+
return; // skipped on Windows without symlink privilege (not a k8s target platform)
56+
57+
var root = NewRoot();
58+
// Pin a single mtime so the ONLY difference between the two revisions is the resolved
59+
// target path — proving detection rides on the resolved target, not on length/mtime.
60+
var pinnedMtime = DateTime.UtcNow.AddMinutes(-5);
61+
BuildConfigMapLayout(root, "AAAAAA", pinnedMtime);
62+
63+
var monitor = ResilientFileSystemMonitor
64+
.Watch(root, "config.json")
65+
.WithSymlinkTargetTracking()
66+
.WithHealthCheckInterval(TimeSpan.FromMilliseconds(100))
67+
.WithAuditInterval(TimeSpan.FromSeconds(1))
68+
.Build();
69+
_disposables.Add(monitor);
70+
71+
var changed = new List<string>();
72+
var gate = new object();
73+
monitor.Changed += (_, e) => { lock (gate) changed.Add(e.Name!); };
74+
75+
// Let the initial index settle so the swap is detected as a change, not a create.
76+
await Task.Delay(300);
77+
78+
// Atomic-ish swap: new revision, identical length + identical mtime, only the resolved
79+
// target differs. config.json itself is never touched.
80+
SwapConfigMapData(root, "revB", "BBBBBB", pinnedMtime);
81+
82+
await ActiveWaitHelpers.WaitUntilAsync(
83+
() => { lock (gate) return changed.Contains("config.json"); },
84+
TimeSpan.FromSeconds(10),
85+
TimeSpan.FromMilliseconds(50),
86+
"Changed event for config.json after ConfigMap-style symlink swap");
87+
88+
lock (gate)
89+
Assert.Contains("config.json", changed);
90+
}
91+
92+
[Fact]
93+
public async Task ConfigMapStyleSwap_WithoutTracking_DoesNotEmitForSymlinkedFile()
94+
{
95+
if (!EnsureSymlinkCapability())
96+
return; // skipped on Windows without symlink privilege (not a k8s target platform)
97+
98+
var root = NewRoot();
99+
var pinnedMtime = DateTime.UtcNow.AddMinutes(-5);
100+
BuildConfigMapLayout(root, "AAAAAA", pinnedMtime);
101+
102+
// No WithSymlinkTargetTracking(): default strict behaviour — reparse points are skipped,
103+
// so the per-key symlink is never indexed and a swap produces no event for config.json.
104+
var monitor = ResilientFileSystemMonitor
105+
.Watch(root, "config.json")
106+
.WithHealthCheckInterval(TimeSpan.FromMilliseconds(100))
107+
.WithAuditInterval(TimeSpan.FromSeconds(1))
108+
.Build();
109+
_disposables.Add(monitor);
110+
111+
var changed = new List<string>();
112+
var gate = new object();
113+
monitor.Changed += (_, e) => { lock (gate) changed.Add(e.Name!); };
114+
115+
await Task.Delay(300);
116+
SwapConfigMapData(root, "revB", "BBBBBB", pinnedMtime);
117+
118+
// Give the audit several cycles to (not) fire for config.json.
119+
await Task.Delay(TimeSpan.FromSeconds(4));
120+
121+
lock (gate)
122+
Assert.DoesNotContain("config.json", changed);
123+
}
124+
125+
[Fact]
126+
public async Task RegularFile_WithTracking_StillEmitsChangedEvents()
127+
{
128+
// Tracking on must not regress ordinary (non-symlinked) file change detection.
129+
if (!EnsureSymlinkCapability())
130+
return; // skipped on Windows without symlink privilege (not a k8s target platform)
131+
132+
var root = NewRoot();
133+
var file = Path.Combine(root, "plain.txt");
134+
File.WriteAllText(file, "initial");
135+
136+
var monitor = ResilientFileSystemMonitor
137+
.Watch(root, "*.txt")
138+
.WithSymlinkTargetTracking()
139+
.WithHealthCheckInterval(TimeSpan.FromMilliseconds(100))
140+
.WithAuditInterval(TimeSpan.FromSeconds(1))
141+
.Build();
142+
_disposables.Add(monitor);
143+
144+
var changed = new List<string>();
145+
var gate = new object();
146+
monitor.Changed += (_, e) => { lock (gate) changed.Add(e.Name!); };
147+
148+
await Task.Delay(200);
149+
File.WriteAllText(file, "modified-content");
150+
151+
await ActiveWaitHelpers.WaitUntilAsync(
152+
() => { lock (gate) return changed.Contains("plain.txt"); },
153+
TimeSpan.FromSeconds(5),
154+
TimeSpan.FromMilliseconds(50),
155+
"Changed event for a regular file with tracking enabled");
156+
157+
lock (gate)
158+
Assert.Contains("plain.txt", changed);
159+
}
160+
161+
[Fact]
162+
public void DanglingSymlink_WithTracking_DoesNotThrow()
163+
{
164+
if (!EnsureSymlinkCapability())
165+
return; // skipped on Windows without symlink privilege (not a k8s target platform)
166+
167+
var root = NewRoot();
168+
// A symlink whose target does not exist (can occur transiently during a swap).
169+
File.CreateSymbolicLink(Path.Combine(root, "config.json"), Path.Combine("data", "config.json"));
170+
171+
var monitor = ResilientFileSystemMonitor
172+
.Watch(root, "config.json")
173+
.WithSymlinkTargetTracking()
174+
.WithHealthCheckInterval(TimeSpan.FromMilliseconds(100))
175+
.WithAuditInterval(TimeSpan.FromSeconds(1))
176+
.Build();
177+
_disposables.Add(monitor);
178+
179+
// Constructing + running the snapshot/audit over a dangling symlink must not throw.
180+
Assert.True(monitor.IsUsingWatcher);
181+
}
182+
183+
// --- helpers -------------------------------------------------------------------------------
184+
185+
private string NewRoot()
186+
{
187+
var root = Path.Combine(_testRoot, Guid.NewGuid().ToString("N"));
188+
Directory.CreateDirectory(root);
189+
return root;
190+
}
191+
192+
/// <summary>
193+
/// Builds a kubelet-style ConfigMap layout (using safe names; real k8s uses "..data" /
194+
/// "..&lt;timestamp&gt;"):
195+
/// <code>
196+
/// config.json -> data/config.json (per-key file symlink, stable)
197+
/// data -> revA (intermediate dir symlink, the swapped one)
198+
/// revA/config.json (the real file)
199+
/// </code>
200+
/// </summary>
201+
private static void BuildConfigMapLayout(string root, string content, DateTime mtimeUtc)
202+
{
203+
var revA = Path.Combine(root, "revA");
204+
Directory.CreateDirectory(revA);
205+
var realFile = Path.Combine(revA, "config.json");
206+
File.WriteAllText(realFile, content);
207+
File.SetLastWriteTimeUtc(realFile, mtimeUtc);
208+
209+
Directory.CreateSymbolicLink(Path.Combine(root, "data"), revA);
210+
// Relative target, exactly as kubelet writes it.
211+
File.CreateSymbolicLink(Path.Combine(root, "config.json"), Path.Combine("data", "config.json"));
212+
}
213+
214+
/// <summary>
215+
/// Performs the atomic-style update: writes a fresh revision (same length + same mtime to defeat
216+
/// metadata-only detection) and repoints the intermediate "data" symlink to it. The user-visible
217+
/// config.json symlink is never modified.
218+
/// </summary>
219+
private static void SwapConfigMapData(string root, string newRev, string content, DateTime mtimeUtc)
220+
{
221+
var revDir = Path.Combine(root, newRev);
222+
Directory.CreateDirectory(revDir);
223+
var realFile = Path.Combine(revDir, "config.json");
224+
File.WriteAllText(realFile, content);
225+
File.SetLastWriteTimeUtc(realFile, mtimeUtc);
226+
227+
var dataLink = Path.Combine(root, "data");
228+
Directory.Delete(dataLink);
229+
Directory.CreateSymbolicLink(dataLink, revDir);
230+
}
231+
232+
/// <summary>
233+
/// Symlink tests must really execute on the production-relevant platforms (Linux/macOS), where
234+
/// creating a symlink never needs elevation. On Windows it requires Developer Mode or an
235+
/// elevated process, and Windows is not a Kubernetes ConfigMap target — so a no-op skip is
236+
/// acceptable there but is NEVER allowed on Linux/macOS (that would be a false-green on the
237+
/// platform we actually ship to). Returns true if the test body should run.
238+
/// </summary>
239+
private static bool EnsureSymlinkCapability()
240+
{
241+
if (SymlinksSupported)
242+
return true;
243+
244+
Assert.True(OperatingSystem.IsWindows(),
245+
"Symlink creation must be available on Linux/macOS test hosts, but it was not — " +
246+
"the symlink target-tracking tests would otherwise silently pass without testing anything.");
247+
248+
return false; // Windows without symlink privilege: skip.
249+
}
250+
251+
private static bool CheckSymlinkSupport()
252+
{
253+
var dir = Path.Combine(Path.GetTempPath(), $"symlink_cap_{Guid.NewGuid():N}");
254+
Directory.CreateDirectory(dir);
255+
try
256+
{
257+
var target = Path.Combine(dir, "t.txt");
258+
File.WriteAllText(target, "x");
259+
var link = Path.Combine(dir, "l.txt");
260+
File.CreateSymbolicLink(link, target);
261+
return File.Exists(link);
262+
}
263+
catch
264+
{
265+
return false;
266+
}
267+
finally
268+
{
269+
try { Directory.Delete(dir, recursive: true); } catch { /* ignore */ }
270+
}
271+
}
272+
}

src/Cocoar.FileSystem/MonitorBuilder.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public sealed class MonitorBuilder
1919
private int _internalBufferSize = 64 * 1024;
2020
private bool _enableAdaptiveHashOnReconcile;
2121
private int _adaptiveHashBytesPerEdge = 64 * 1024;
22+
private bool _trackSymlinkTargets;
2223

2324
private EventHandler<FileSystemEventArgs>? _created;
2425
private EventHandler<FileSystemEventArgs>? _changed;
@@ -226,6 +227,23 @@ public MonitorBuilder WithAdaptiveHashing(int bytesPerEdge = 64 * 1024)
226227
return this;
227228
}
228229

230+
/// <summary>
231+
/// Enables symlink target tracking. When enabled, the monitor follows a watched symlink to
232+
/// its final target and folds that resolved target into the change fingerprint, so an atomic
233+
/// symlink-target swap (e.g. a Kubernetes ConfigMap/Secret "..data" update) is detected and
234+
/// surfaced as a Changed event on the user-visible (symlinked) path. Off by default.
235+
/// </summary>
236+
/// <remarks>
237+
/// Only the final target is resolved; the monitor does not recurse into it, so loop-safety is
238+
/// preserved. Resolution happens only for reparse-point entries, so ordinary (non-symlinked)
239+
/// files incur no extra cost. Primarily relevant on Linux/container hosts.
240+
/// </remarks>
241+
public MonitorBuilder WithSymlinkTargetTracking()
242+
{
243+
_trackSymlinkTargets = true;
244+
return this;
245+
}
246+
229247
public MonitorBuilder OnCreated(EventHandler<FileSystemEventArgs> handler)
230248
{
231249
ArgumentNullException.ThrowIfNull(handler);
@@ -289,7 +307,8 @@ public ResilientFileSystemMonitor Build()
289307
NotifyFilter = _notifyFilter,
290308
InternalBufferSize = _internalBufferSize,
291309
EnableAdaptiveHashOnReconcile = _enableAdaptiveHashOnReconcile,
292-
AdaptiveHashBytesPerEdge = _adaptiveHashBytesPerEdge
310+
AdaptiveHashBytesPerEdge = _adaptiveHashBytesPerEdge,
311+
TrackSymlinkTargets = _trackSymlinkTargets
293312
};
294313

295314
var monitor = new ResilientFileSystemMonitor(options);

0 commit comments

Comments
 (0)