Skip to content

Commit 14bbceb

Browse files
nosanbclozel
authored andcommitted
Watch symlinks for changes for SSL hot reloading
Prior to this commit, the SSL file hot reloading feature would create file watchers for all configured locations and would resolve symbolic links if they're found as registrations. This arrangement would work for typical Let's Encrypt setups, but would not get notified of consecutive changes for k8s setups. Kubernetes uses a mix of symlinks and atomic file moves to update secrets. This commit not only tracks changes to symlinks targets, but also to the original symlink. Closes gh-44807 Signed-off-by: Dmytro Nosan <[email protected]> [[email protected]: apply code conventions] Signed-off-by: Brian Clozel <[email protected]>
1 parent 4f91d41 commit 14bbceb

File tree

2 files changed

+119
-17
lines changed
  • spring-boot-project/spring-boot-autoconfigure/src

2 files changed

+119
-17
lines changed

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java

+45-16
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
import java.util.concurrent.ConcurrentHashMap;
3636
import java.util.concurrent.CopyOnWriteArrayList;
3737
import java.util.concurrent.TimeUnit;
38-
import java.util.stream.Collectors;
3938

4039
import org.apache.commons.logging.Log;
4140
import org.apache.commons.logging.LogFactory;
@@ -86,26 +85,18 @@ void watch(Set<Path> paths, Runnable action) {
8685
this.thread = new WatcherThread();
8786
this.thread.start();
8887
}
89-
Set<Path> actualPaths = new HashSet<>();
88+
Set<Path> registrationPaths = new HashSet<>();
9089
for (Path path : paths) {
91-
actualPaths.add(resolveSymlinkIfNecessary(path));
90+
registrationPaths.addAll(getRegistrationPaths(path));
9291
}
93-
this.thread.register(new Registration(actualPaths, action));
92+
this.thread.register(new Registration(registrationPaths, action));
9493
}
9594
catch (IOException ex) {
9695
throw new UncheckedIOException("Failed to register paths for watching: " + paths, ex);
9796
}
9897
}
9998
}
10099

101-
private static Path resolveSymlinkIfNecessary(Path path) throws IOException {
102-
if (Files.isSymbolicLink(path)) {
103-
Path target = path.resolveSibling(Files.readSymbolicLink(path));
104-
return resolveSymlinkIfNecessary(target);
105-
}
106-
return path;
107-
}
108-
109100
@Override
110101
public void close() throws IOException {
111102
synchronized (this.lock) {
@@ -123,6 +114,44 @@ public void close() throws IOException {
123114
}
124115
}
125116

117+
/**
118+
* Retrieves all {@link Path Paths} that should be registered for the specified
119+
* {@link Path}. If the path is a symlink, changes to the symlink should be monitored,
120+
* not just the file it points to. For example, for the given {@code keystore.jks}
121+
* path in the following directory structure:<pre>
122+
* .
123+
* ├── ..a72e81ff-f0e1-41d8-a19b-068d3d1d4e2f
124+
* │ ├── keystore.jks
125+
* ├── ..data -> ..a72e81ff-f0e1-41d8-a19b-068d3d1d4e2f
126+
* ├── keystore.jks -> ..data/keystore.jks
127+
* </pre> the resulting paths would include:
128+
* <ul>
129+
* <li><b>keystore.jks</b></li>
130+
* <li><b>..data/keystore.jks</b></li>
131+
* <li><b>..data</b></li>
132+
* <li><b>..a72e81ff-f0e1-41d8-a19b-068d3d1d4e2f/keystore.jks</b></li>
133+
* </ul>
134+
* @param path the path
135+
* @return all possible {@link Path} instances to be registered
136+
* @throws IOException if an I/O error occurs
137+
*/
138+
private static Set<Path> getRegistrationPaths(Path path) throws IOException {
139+
path = path.toAbsolutePath();
140+
Set<Path> result = new HashSet<>();
141+
result.add(path);
142+
Path parent = path.getParent();
143+
if (parent != null && Files.isSymbolicLink(parent)) {
144+
result.add(parent);
145+
Path target = parent.resolveSibling(Files.readSymbolicLink(parent));
146+
result.addAll(getRegistrationPaths(target.resolve(path.getFileName())));
147+
}
148+
else if (Files.isSymbolicLink(path)) {
149+
Path target = path.resolveSibling(Files.readSymbolicLink(path));
150+
result.addAll(getRegistrationPaths(target));
151+
}
152+
return result;
153+
}
154+
126155
/**
127156
* The watcher thread used to check for changes.
128157
*/
@@ -145,11 +174,15 @@ private void onThreadException(Thread thread, Throwable throwable) {
145174
}
146175

147176
void register(Registration registration) throws IOException {
177+
Set<Path> directories = new HashSet<>();
148178
for (Path path : registration.paths()) {
149179
if (!Files.isRegularFile(path) && !Files.isDirectory(path)) {
150180
throw new IOException("'%s' is neither a file nor a directory".formatted(path));
151181
}
152182
Path directory = Files.isDirectory(path) ? path : path.getParent();
183+
directories.add(directory);
184+
}
185+
for (Path directory : directories) {
153186
WatchKey watchKey = register(directory);
154187
this.registrations.computeIfAbsent(watchKey, (key) -> new CopyOnWriteArrayList<>()).add(registration);
155188
}
@@ -224,10 +257,6 @@ public void close() throws IOException {
224257
*/
225258
private record Registration(Set<Path> paths, Runnable action) {
226259

227-
Registration {
228-
paths = paths.stream().map(Path::toAbsolutePath).collect(Collectors.toSet());
229-
}
230-
231260
boolean manages(Path file) {
232261
Path absolutePath = file.toAbsolutePath();
233262
return this.paths.contains(absolutePath) || isInDirectories(absolutePath);

spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/ssl/FileWatcherTests.java

+74-1
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818

1919
import java.io.IOException;
2020
import java.io.UncheckedIOException;
21+
import java.nio.file.AccessDeniedException;
2122
import java.nio.file.Files;
2223
import java.nio.file.Path;
24+
import java.nio.file.StandardCopyOption;
2325
import java.time.Duration;
2426
import java.util.Set;
2527
import java.util.UUID;
@@ -254,6 +256,62 @@ void shouldTriggerOnConfigMapUpdates(@TempDir Path tempDir) throws Exception {
254256
}
255257
}
256258

259+
/**
260+
* Updates many times K8s ConfigMap/Secret with atomic move. <pre>
261+
* .
262+
* ├── ..a72e81ff-f0e1-41d8-a19b-068d3d1d4e2f
263+
* │ ├── keystore.jks
264+
* ├── ..data -> ..a72e81ff-f0e1-41d8-a19b-068d3d1d4e2f
265+
* ├── keystore.jks -> ..data/keystore.jks
266+
* </pre>
267+
*
268+
* After a first a ConfigMap/Secret update, this will look like: <pre>
269+
* .
270+
* ├── ..bba2a61f-ce04-4c35-93aa-e455110d4487
271+
* │ ├── keystore.jks
272+
* ├── ..data -> ..bba2a61f-ce04-4c35-93aa-e455110d4487
273+
* ├── keystore.jks -> ..data/keystore.jks
274+
* </pre> After a second a ConfigMap/Secret update, this will look like: <pre>
275+
* .
276+
* ├── ..134887f0-df8f-4433-b70c-7784d2a33bd1
277+
* │ ├── keystore.jks
278+
* ├── ..data -> ..134887f0-df8f-4433-b70c-7784d2a33bd1
279+
* ├── keystore.jks -> ..data/keystore.jks
280+
*</pre>
281+
* <p>
282+
* When Kubernetes updates either the ConfigMap or Secret, it performs the following
283+
* steps:
284+
* <ul>
285+
* <li>Creates a new unique directory.</li>
286+
* <li>Writes the ConfigMap/Secret content to the newly created directory.</li>
287+
* <li>Creates a symlink {@code ..data_tmp} pointing to the newly created
288+
* directory.</li>
289+
* <li>Performs an atomic rename of {@code ..data_tmp} to {@code ..data}.</li>
290+
* <li>Deletes the old ConfigMap/Secret directory.</li>
291+
* </ul>
292+
*/
293+
@Test
294+
void shouldTriggerOnConfigMapAtomicMoveUpdates(@TempDir Path tempDir) throws Exception {
295+
Path configMap1 = createConfigMap(tempDir, "keystore.jks");
296+
Path data = Files.createSymbolicLink(tempDir.resolve("..data"), configMap1);
297+
Files.createSymbolicLink(tempDir.resolve("keystore.jks"), data.resolve("keystore.jks"));
298+
WaitingCallback callback = new WaitingCallback();
299+
this.fileWatcher.watch(Set.of(tempDir.resolve("keystore.jks")), callback);
300+
// First update
301+
Path configMap2 = createConfigMap(tempDir, "keystore.jks");
302+
Path dataTmp = Files.createSymbolicLink(tempDir.resolve("..data_tmp"), configMap2);
303+
move(dataTmp, data);
304+
FileSystemUtils.deleteRecursively(configMap1);
305+
callback.expectChanges();
306+
callback.reset();
307+
// Second update
308+
Path configMap3 = createConfigMap(tempDir, "keystore.jks");
309+
dataTmp = Files.createSymbolicLink(tempDir.resolve("..data_tmp"), configMap3);
310+
move(dataTmp, data);
311+
FileSystemUtils.deleteRecursively(configMap2);
312+
callback.expectChanges();
313+
}
314+
257315
Path createConfigMap(Path parentDir, String secretFileName) throws IOException {
258316
Path configMapFolder = parentDir.resolve(".." + UUID.randomUUID());
259317
Files.createDirectory(configMapFolder);
@@ -262,9 +320,19 @@ Path createConfigMap(Path parentDir, String secretFileName) throws IOException {
262320
return configMapFolder;
263321
}
264322

323+
private void move(Path source, Path target) throws IOException {
324+
try {
325+
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
326+
}
327+
catch (AccessDeniedException ex) {
328+
// Windows
329+
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
330+
}
331+
}
332+
265333
private static final class WaitingCallback implements Runnable {
266334

267-
private final CountDownLatch latch = new CountDownLatch(1);
335+
private CountDownLatch latch = new CountDownLatch(1);
268336

269337
volatile boolean changed = false;
270338

@@ -292,6 +360,11 @@ void waitForChanges(boolean fail) throws InterruptedException {
292360
}
293361
}
294362

363+
void reset() {
364+
this.latch = new CountDownLatch(1);
365+
this.changed = false;
366+
}
367+
295368
}
296369

297370
}

0 commit comments

Comments
 (0)