Skip to content

Commit d47fe00

Browse files
committed
Merge branch '3.4.x'
Closes gh-44890
2 parents 87dd039 + ac9ad45 commit d47fe00

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)