Skip to content

Commit 7cc8f80

Browse files
authored
Support for mutable file based MP config sources. (helidon-io#3666) (helidon-io#3730)
Signed-off-by: Tomas Langer <tomas.langer@oracle.com> (cherry picked from commit 5ee165b)
1 parent cb7ce97 commit 7cc8f80

4 files changed

Lines changed: 267 additions & 9 deletions

File tree

config/config-mp/src/main/java/io/helidon/config/mp/MpConfigSources.java

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.net.URLConnection;
2323
import java.nio.file.Files;
2424
import java.nio.file.Path;
25+
import java.time.Duration;
2526
import java.util.Enumeration;
2627
import java.util.HashMap;
2728
import java.util.HashSet;
@@ -34,6 +35,7 @@
3435

3536
import io.helidon.config.Config;
3637
import io.helidon.config.ConfigException;
38+
import io.helidon.config.MutabilitySupport;
3739

3840
import org.eclipse.microprofile.config.spi.ConfigSource;
3941

@@ -158,6 +160,19 @@ public static ConfigSource create(String name, Path path) {
158160
throw new ConfigException("Failed to read properties from " + path.toAbsolutePath());
159161
}
160162

163+
if ("true".equals(props.getProperty("helidon.config.polling.enabled"))) {
164+
String durationString = props.getProperty("helidon.config.polling.duration");
165+
Duration duration;
166+
if (durationString == null) {
167+
duration = Duration.ofSeconds(10);
168+
} else {
169+
duration = Duration.parse(durationString);
170+
}
171+
MutabilitySupport.poll(path, duration, changed -> update(path, props), changed -> props.clear());
172+
} else if ("true".equals(props.getProperty("helidon.config.watcher.enabled"))) {
173+
MutabilitySupport.watch(path, changed -> update(path, props), changed -> props.clear());
174+
}
175+
161176
return create(name, props);
162177
}
163178

@@ -184,12 +199,10 @@ public static ConfigSource create(Properties properties) {
184199
* @param properties serving as configuration data
185200
* @return a new config source
186201
*/
202+
@SuppressWarnings("rawtypes")
187203
public static ConfigSource create(String name, Properties properties) {
188-
Map<String, String> result = new HashMap<>();
189-
for (String key : properties.stringPropertyNames()) {
190-
result.put(key, properties.getProperty(key));
191-
}
192-
return new MpMapSource(name, result);
204+
Map map = properties;
205+
return new MpMapSource(name, map);
193206
}
194207

195208
/**
@@ -411,4 +424,17 @@ private static String toProfileResource(String resource, String profile) {
411424
}
412425
return resource + "-" + profile;
413426
}
427+
428+
private static void update(Path path, Properties originalProperties) {
429+
Properties props = new Properties();
430+
431+
try (InputStream in = Files.newInputStream(path)) {
432+
props.load(in);
433+
} catch (IOException e) {
434+
throw new ConfigException("Failed to read properties from " + path.toAbsolutePath());
435+
}
436+
437+
originalProperties.keySet().removeIf(it -> !props.containsKey(it));
438+
originalProperties.putAll(props);
439+
}
414440
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
* Copyright (c) 2021 Oracle and/or its affiliates.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.helidon.config;
18+
19+
import java.io.IOException;
20+
import java.io.UncheckedIOException;
21+
import java.nio.file.Files;
22+
import java.nio.file.Path;
23+
import java.time.Duration;
24+
import java.time.Instant;
25+
import java.util.concurrent.Executors;
26+
import java.util.concurrent.ScheduledExecutorService;
27+
import java.util.function.Consumer;
28+
import java.util.logging.Level;
29+
import java.util.logging.Logger;
30+
31+
import io.helidon.common.LazyValue;
32+
import io.helidon.common.NativeImageHelper;
33+
import io.helidon.config.spi.ChangeEventType;
34+
import io.helidon.config.spi.PollingStrategy;
35+
36+
/**
37+
* Mutability support for file based sources.
38+
* <p>
39+
* Provides support for polling based strategy
40+
* ({@link #poll(java.nio.file.Path, java.time.Duration, java.util.function.Consumer, java.util.function.Consumer)}) and
41+
* for file watching ({@link #watch(java.nio.file.Path, java.util.function.Consumer, java.util.function.Consumer)}).
42+
*/
43+
public final class MutabilitySupport {
44+
private static final Logger LOGGER = Logger.getLogger(MutabilitySupport.class.getName());
45+
private static final LazyValue<ScheduledExecutorService> EXECUTOR
46+
= LazyValue.create(Executors::newSingleThreadScheduledExecutor);
47+
48+
private MutabilitySupport() {
49+
}
50+
51+
/**
52+
* Start polling for changes.
53+
*
54+
* @param path path to watch
55+
* @param duration duration of polling
56+
* @param updater consumer that reads the file content and updates properties (in case file is changed)
57+
* @param cleaner runnable to clean the properties (in case file is deleted)
58+
* @return runnable to stop the file watcher
59+
*/
60+
public static Runnable poll(Path path, Duration duration, Consumer<Path> updater, Consumer<Path> cleaner) {
61+
if (NativeImageHelper.isBuildTime()) {
62+
LOGGER.info("File polling is not enabled in native image build time. Path: " + path);
63+
}
64+
65+
PollingStrategy strategy = PollingStrategies.regular(duration)
66+
.executor(EXECUTOR.get())
67+
.build();
68+
69+
strategy.start(new PathPolled(path, updater, cleaner));
70+
return strategy::stop;
71+
}
72+
73+
/**
74+
* Start watching a file for changes.
75+
*
76+
* @param path path to watch
77+
* @param updater consumer that reads the file content and updates properties
78+
* @param cleaner runnable to clean the properties (in case file is deleted)
79+
* @return runnable to stop the file watcher
80+
*/
81+
public static Runnable watch(Path path, Consumer<Path> updater, Consumer<Path> cleaner) {
82+
if (NativeImageHelper.isBuildTime()) {
83+
LOGGER.info("File watching is not enabled in native image build time. Path: " + path);
84+
}
85+
FileSystemWatcher watcher = FileSystemWatcher.builder()
86+
.executor(EXECUTOR.get())
87+
.build();
88+
89+
watcher.start(path, event -> {
90+
try {
91+
if (event.type() == ChangeEventType.DELETED) {
92+
cleaner.accept(event.target());
93+
} else {
94+
updater.accept(event.target());
95+
}
96+
} catch (Exception e) {
97+
LOGGER.log(Level.WARNING, "Failed to process change watcher event " + event
98+
+ " for file " + path.toAbsolutePath(), e);
99+
}
100+
});
101+
return watcher::stop;
102+
}
103+
104+
private static class PathPolled implements PollingStrategy.Polled {
105+
private final Path path;
106+
private final Consumer<Path> updater;
107+
private final Consumer<Path> cleaner;
108+
109+
private boolean exists;
110+
private Instant lastChange;
111+
112+
private PathPolled(Path path,
113+
Consumer<Path> updater,
114+
Consumer<Path> cleaner) {
115+
116+
this.path = path;
117+
this.updater = updater;
118+
this.cleaner = cleaner;
119+
this.exists = Files.exists(path);
120+
if (exists) {
121+
try {
122+
this.lastChange = Files.getLastModifiedTime(path).toInstant();
123+
} catch (IOException e) {
124+
throw new UncheckedIOException(e);
125+
}
126+
}
127+
}
128+
129+
@Override
130+
public ChangeEventType poll(Instant when) {
131+
try {
132+
return doPoll();
133+
} catch (Exception e) {
134+
LOGGER.log(Level.WARNING, "Failed to poll for changes at " + when, e);
135+
return ChangeEventType.CHANGED;
136+
}
137+
}
138+
139+
private ChangeEventType doPoll() {
140+
if (Files.exists(path)) {
141+
ChangeEventType response;
142+
if (exists) {
143+
// existed and exists now, let's see if modified
144+
Instant instant = Instant.now();
145+
try {
146+
instant = Files.getLastModifiedTime(path).toInstant();
147+
} catch (IOException e) {
148+
LOGGER.log(Level.WARNING, "Failed to get last modified for " + path.toAbsolutePath(), e);
149+
}
150+
if (instant.isAfter(this.lastChange)) {
151+
this.lastChange = instant;
152+
response = ChangeEventType.CHANGED;
153+
updater.accept(path);
154+
} else {
155+
response = ChangeEventType.UNCHANGED;
156+
}
157+
} else {
158+
response = ChangeEventType.CREATED;
159+
updater.accept(path);
160+
}
161+
exists = true;
162+
return response;
163+
} else {
164+
ChangeEventType response;
165+
if (exists) {
166+
response = ChangeEventType.DELETED;
167+
cleaner.accept(path);
168+
} else {
169+
response = ChangeEventType.UNCHANGED;
170+
}
171+
exists = false;
172+
return response;
173+
}
174+
}
175+
}
176+
}

config/yaml-mp/src/main/java/io/helidon/config/yaml/mp/YamlMpConfigSource.java

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616

1717
package io.helidon.config.yaml.mp;
1818

19+
import java.io.BufferedReader;
1920
import java.io.IOException;
2021
import java.io.InputStreamReader;
2122
import java.io.Reader;
22-
import java.net.MalformedURLException;
2323
import java.net.URL;
2424
import java.nio.charset.StandardCharsets;
25+
import java.nio.file.Files;
2526
import java.nio.file.Path;
27+
import java.time.Duration;
2628
import java.util.Collections;
2729
import java.util.Enumeration;
2830
import java.util.HashMap;
@@ -34,6 +36,7 @@
3436
import java.util.Set;
3537

3638
import io.helidon.config.ConfigException;
39+
import io.helidon.config.MutabilitySupport;
3740

3841
import org.eclipse.microprofile.config.spi.ConfigSource;
3942
import org.yaml.snakeyaml.Yaml;
@@ -92,13 +95,47 @@ private YamlMpConfigSource(String name, Map<String, String> properties) {
9295
* @see #create(java.net.URL)
9396
*/
9497
public static ConfigSource create(Path path) {
95-
try {
96-
return create(path.toUri().toURL());
97-
} catch (MalformedURLException e) {
98+
String name = path.toAbsolutePath().toString();
99+
100+
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
101+
Map yamlMap = toMap(reader);
102+
// this is a mutable HashMap that we can use
103+
Map<String, String> props = fromMap(yamlMap == null ? Map.of() : yamlMap);
104+
105+
if ("true".equals(props.get("helidon.config.polling.enabled"))) {
106+
String durationString = props.get("helidon.config.polling.duration");
107+
Duration duration;
108+
if (durationString == null) {
109+
duration = Duration.ofSeconds(10);
110+
} else {
111+
duration = Duration.parse(durationString);
112+
}
113+
MutabilitySupport.poll(path, duration, changed -> update(path, props), changed -> props.clear());
114+
} else if ("true".equals(props.get("helidon.config.watcher.enabled"))) {
115+
MutabilitySupport.watch(path, changed -> update(path, props), changed -> props.clear());
116+
}
117+
118+
119+
return new YamlMpConfigSource(name, props);
120+
} catch (IOException e) {
98121
throw new ConfigException("Failed to load YAML config source from path: " + path.toAbsolutePath(), e);
99122
}
100123
}
101124

125+
private static void update(Path path, Map<String, String> originalProps) {
126+
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
127+
Map yamlMap = toMap(reader);
128+
// this is a mutable HashMap that we can use
129+
Map<String, String> props = fromMap(yamlMap == null ? Map.of() : yamlMap);
130+
131+
// first delete those that no longer exist
132+
originalProps.keySet().removeIf(it -> !props.containsKey(it));
133+
originalProps.putAll(props);
134+
} catch (IOException e) {
135+
throw new ConfigException("Failed to load updated YAML config source from path: " + path.toAbsolutePath(), e);
136+
}
137+
}
138+
102139
/**
103140
* Load a YAML config source from URL.
104141
* The URL may be any URL which is support by the used JVM.

docs/mp/config/01_introduction.adoc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,25 @@ service-1: "${uri}/service1"
117117
service-2: "${uri}/service2"
118118
----
119119
120+
* *Change support* +
121+
Polling (or change watching) for file based config sources (not classpath based).
122+
123+
To enable polling for a config source created using meta configuration (see below), or using
124+
`MpConfigSources.create(Path)`, or `YamlMpConfigSource.create(Path)`, use the following properties:
125+
126+
[cols="3,5"]
127+
|===
128+
|Property |Description
129+
130+
|`helidon.config.polling.enabled` |To enable polling file for changes, uses timestamp to identify a change.
131+
132+
|`helidon.config.polling.duration` |Polling period duration, defaults to 10 seconds ('PT10S`) +
133+
See https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/time/Duration.html#parse(java.lang.CharSequence)
134+
135+
|`helidon.config.watcher.enabled` |To enable watching file for changes using the Java `WatchService`. +
136+
See https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/WatchService.html
137+
138+
|===
120139
121140
* *Encryption* +
122141
You can encrypt secrets using a master password and store them in a configuration file.

0 commit comments

Comments
 (0)