diff --git a/docs/src/main/asciidoc/spring-cloud-config.adoc b/docs/src/main/asciidoc/spring-cloud-config.adoc
index 321f780e..4bf1e230 100644
--- a/docs/src/main/asciidoc/spring-cloud-config.adoc
+++ b/docs/src/main/asciidoc/spring-cloud-config.adoc
@@ -489,7 +489,7 @@ TIP: the `{name:value}` prefixes can also be added to plaintext posted
to the `/encrypt` endpoint, if you want to let the Config Server
handle all encryption as well as decryption.
-=== Embedding the Config Server
+== Embedding the Config Server
The Config Server runs best as a standalone application, but if you
need to you can embed it in another application. Just use the
@@ -505,6 +505,41 @@ own remote repository. The flag is off by default because it can delay
startup, but when embedded in another application it makes sense to
initialize the same way as any other application.
+== Push Notifications and Spring Cloud Bus
+
+Many source code repository providers (like Github or Gitlab for
+instance) will notify you of changes in a repository through a
+webhook. You can configure the webhook via the provider's user
+interface as a URL and a set of events in which you are
+interested. For instance
+https://developer.github.com/v3/activity/events/types/#pushevent[Github]
+will POST to the webhook with a JSON body containing a list of
+commits, and a header "X-Github-Event" equal to "push". If you add a
+dependency on the `spring-cloud-config-monitor` library and activate
+the Spring Cloud Bus in your Config Server, then a "/monitor" endpoint
+is enabled.
+
+When the webhook is activated the Config Server will send a
+`RefreshRemoteApplicationEvent` targeted at the applications it thinks
+might have changed. The change detection can be strategized, but by
+default it just looks for changes in files that match the application
+name (e.g. "foo.properties" is targeted at the "foo" application, and
+"application.properties" is targeted at all applications). The strategy if you want to override the behaviour is `PropertyPathNotificationExtractor` which accepts the request headers and body as parameters and returns a list of file paths that changed.
+
+The default configuration works out of the box with Github or
+Gitlab. In addition to the JSON notifications from Github and Gitlab
+you can trigger a change notification by POSTing to "/monitor" with a
+form-encoded body parameters `path={name}`. This will broadcast to
+applications matching the "{name}" pattern (can contain wildcards).
+
+NOTE: the `RefreshRemoteApplicationEvent` will only be transmitted if
+the `spring-cloud-bus` is activated in the Config Server and in the
+client application.
+
+NOTE: the default configuration also detects filesystem changes in
+local git repositories (the webhook is not used in that case but as
+soon as you edit a config file a refresh will be broadcast).
+
== Spring Cloud Config Client
A Spring Boot application can take immediate advantage of the Spring
diff --git a/pom.xml b/pom.xml
index 36c3d3c3..64fe29d2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -22,13 +22,15 @@
config
+ 1.1.0.BUILD-SNAPSHOT
1.1.0.BUILD-SNAPSHOT
spring-cloud-config-client
spring-cloud-config-server
+ spring-cloud-config-monitor
spring-cloud-config-sample
- spring-cloud-starter-config
+ spring-cloud-starter-config
docs
@@ -55,6 +57,11 @@
spring-cloud-config-server
1.1.0.BUILD-SNAPSHOT
+
+ org.springframework.cloud
+ spring-cloud-config-monitor
+ 1.1.0.BUILD-SNAPSHOT
+
org.springframework.retry
spring-retry
diff --git a/spring-cloud-config-monitor/pom.xml b/spring-cloud-config-monitor/pom.xml
new file mode 100644
index 00000000..08d32b15
--- /dev/null
+++ b/spring-cloud-config-monitor/pom.xml
@@ -0,0 +1,49 @@
+
+
+ 4.0.0
+
+ org.springframework.cloud
+ spring-cloud-config
+ 1.1.0.BUILD-SNAPSHOT
+ ..
+
+ spring-cloud-config-monitor
+ spring-cloud-config-monitor
+ Spring Cloud Config Monitor
+
+ ${basedir}/../..
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-bus-parent
+ ${spring-cloud-bus.version}
+ pom
+ import
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-config-server
+
+
+ org.springframework.cloud
+ spring-cloud-bus
+
+
+ org.projectlombok
+ lombok
+
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
diff --git a/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/CompositePropertyPathNotificationExtractor.java b/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/CompositePropertyPathNotificationExtractor.java
new file mode 100644
index 00000000..4d244ddd
--- /dev/null
+++ b/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/CompositePropertyPathNotificationExtractor.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.config.monitor;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.AnnotationAwareOrderComparator;
+import org.springframework.core.annotation.Order;
+import org.springframework.util.MultiValueMap;
+
+/**
+ * A {@link PropertyPathNotificationExtractor} that cycles through a set of (ordered) delegates,
+ * looking for the first non-null outcome.
+ *
+ * @author Dave Syer
+ *
+ */
+public class CompositePropertyPathNotificationExtractor
+ implements PropertyPathNotificationExtractor {
+
+ private List extractors;
+
+ public CompositePropertyPathNotificationExtractor(
+ List extractors) {
+ this.extractors = new ArrayList<>();
+ if (extractors != null) {
+ this.extractors.addAll(extractors);
+ }
+ this.extractors.add(new SimplePropertyPathNotificationExtractor());
+ AnnotationAwareOrderComparator.sort(this.extractors);
+ }
+
+ @Override
+ public PropertyPathNotification extract(MultiValueMap headers,
+ Map request) {
+ for (PropertyPathNotificationExtractor extractor : this.extractors) {
+ PropertyPathNotification result = extractor.extract(headers, request);
+ if (result != null) {
+ return result;
+ }
+ }
+ return null;
+ }
+
+ @Order(Ordered.LOWEST_PRECEDENCE - 200)
+ private static class SimplePropertyPathNotificationExtractor
+ implements PropertyPathNotificationExtractor {
+
+ @Override
+ public PropertyPathNotification extract(MultiValueMap headers,
+ Map request) {
+ Object object = request.get("path");
+ if (object instanceof String) {
+ return new PropertyPathNotification((String) object);
+ }
+ if (object instanceof Collection) {
+ @SuppressWarnings("unchecked")
+ Collection collection = (Collection) object;
+ return new PropertyPathNotification(collection.toArray(new String[0]));
+ }
+ return null;
+ }
+
+ }
+
+}
diff --git a/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/EnvironmentMonitorAutoConfiguration.java b/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/EnvironmentMonitorAutoConfiguration.java
new file mode 100644
index 00000000..2e937799
--- /dev/null
+++ b/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/EnvironmentMonitorAutoConfiguration.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.config.monitor;
+
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+
+/**
+ * @author Dave Syer
+ *
+ */
+@Configuration
+@ConditionalOnWebApplication
+@Import(FileMonitorConfiguration.class)
+public class EnvironmentMonitorAutoConfiguration {
+
+ @Autowired(required=false)
+ private List extractors;
+
+ @Bean
+ public PropertyPathEndpoint propertyPathEndpoint() {
+ return new PropertyPathEndpoint(new CompositePropertyPathNotificationExtractor(this.extractors));
+ }
+}
diff --git a/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/FileMonitorConfiguration.java b/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/FileMonitorConfiguration.java
new file mode 100644
index 00000000..9a859c6a
--- /dev/null
+++ b/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/FileMonitorConfiguration.java
@@ -0,0 +1,295 @@
+/*
+ * Copyright 2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.config.monitor;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileSystems;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.StandardWatchEventKinds;
+import java.nio.file.WatchEvent;
+import java.nio.file.WatchKey;
+import java.nio.file.WatchService;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cloud.config.server.AbstractScmEnvironmentRepository;
+import org.springframework.cloud.config.server.NativeEnvironmentRepository;
+import org.springframework.context.ResourceLoaderAware;
+import org.springframework.context.SmartLifecycle;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.PatternMatchUtils;
+
+import lombok.extern.apachecommons.CommonsLog;
+
+/**
+ * Configuration for a file watcher that detects changes in local files related to the
+ * environment repository. If any files change the {@link PropertyPathEndpoint} is pinged
+ * with the paths of the files. This applies to the source files of a local git repository
+ * (i.e. a git repository with a "file:" URI) or to a native repository.
+ *
+ * @author Dave Syer
+ *
+ */
+@Configuration
+@CommonsLog
+@EnableScheduling
+public class FileMonitorConfiguration implements SmartLifecycle, ResourceLoaderAware {
+
+ @Autowired
+ PropertyPathEndpoint endpoint;
+
+ @Autowired(required = false)
+ AbstractScmEnvironmentRepository scmRepository;
+
+ @Autowired(required = false)
+ NativeEnvironmentRepository nativeEnvironmentRepository;
+
+ private boolean running;
+
+ private WatchService watcher;
+
+ private Set directory;
+
+ private int phase;
+
+ private boolean autoStartup = true;
+
+ private ResourceLoader resourceLoader;
+
+ private String[] excludes = new String[] { ".*", "#*", "*#" };
+
+ @Override
+ public void setResourceLoader(ResourceLoader resourceLoader) {
+ this.resourceLoader = resourceLoader;
+ }
+
+ @Override
+ public int getPhase() {
+ return this.phase;
+ }
+
+ /**
+ * see {@link #getPhase()}
+ * @param phase the phase.
+ */
+ public void setPhase(int phase) {
+ this.phase = phase;
+ }
+
+ @Override
+ public boolean isRunning() {
+ return this.running;
+ }
+
+ /**
+ * @see #isRunning()
+ * @param running true if running.
+ */
+ public void setRunning(boolean running) {
+ this.running = running;
+ }
+
+ @Override
+ public boolean isAutoStartup() {
+ return this.autoStartup;
+ }
+
+ /**
+ * @see #isAutoStartup()
+ * @param autoStartup true to auto start.
+ */
+ public void setAutoStartup(boolean autoStartup) {
+ this.autoStartup = autoStartup;
+ }
+
+ @Override
+ public synchronized void start() {
+ if (!this.running) {
+ this.directory = getFileRepo();
+ if (this.directory != null && !this.directory.isEmpty()) {
+ try {
+ this.watcher = FileSystems.getDefault().newWatchService();
+ for (Path path : this.directory) {
+ walkDirectory(path);
+ }
+ }
+ catch (IOException e) {
+ }
+ }
+ this.running = true;
+ }
+ }
+
+ @Override
+ public synchronized void stop() {
+ if (this.running) {
+ try {
+ this.watcher.close();
+ }
+ catch (IOException e) {
+ log.error("Failed to close watcher for " + this.directory.toString(), e);
+ }
+ this.running = false;
+ }
+ }
+
+ @Override
+ public void stop(Runnable callback) {
+ stop();
+ callback.run();
+ }
+
+ @Scheduled(fixedRateString = "${spring.cloud.config.server.monitor.fixedDelay:5000}")
+ public void poll() {
+ for (File file : filesFromEvents()) {
+ this.endpoint.notifyByPath(new LinkedMultiValueMap(),
+ Collections. singletonMap("path",
+ file.getAbsolutePath()));
+ }
+ }
+
+ private Set getFileRepo() {
+ if (this.scmRepository != null
+ && this.scmRepository.getUri().startsWith("file:")) {
+ try {
+ return Collections.singleton(Paths.get(this.resourceLoader
+ .getResource(this.scmRepository.getUri()).getURI()));
+ }
+ catch (IOException e) {
+ log.error("Cannot resolve URI for path: " + this.scmRepository.getUri());
+ }
+ }
+ if (this.nativeEnvironmentRepository != null) {
+ Set paths = new LinkedHashSet<>();
+ for (String path : this.nativeEnvironmentRepository.getSearchLocations()) {
+ Resource resource = this.resourceLoader.getResource(path);
+ if (resource.exists()) {
+ try {
+ paths.add(Paths.get(resource.getURI()));
+ }
+ catch (IOException e) {
+ log.error("Cannot resolve URI for path: " + path);
+ }
+ }
+ }
+ return paths;
+ }
+ return null;
+ }
+
+ private Set filesFromEvents() {
+ WatchKey key = this.watcher.poll();
+ Set files = new LinkedHashSet();
+ while (key != null) {
+ for (WatchEvent> event : key.pollEvents()) {
+ if (event.kind() == StandardWatchEventKinds.ENTRY_CREATE
+ || event.kind() == StandardWatchEventKinds.ENTRY_MODIFY) {
+ Path item = (Path) event.context();
+ File file = new File(((Path) key.watchable()).toAbsolutePath()
+ + File.separator + item.getFileName());
+ if (file.isDirectory()) {
+ files.addAll(walkDirectory(file.toPath()));
+ }
+ else {
+ if (!file.getPath().contains(".git") && !PatternMatchUtils
+ .simpleMatch(this.excludes, file.getName())) {
+ if (log.isDebugEnabled()) {
+ log.debug("Watch Event: " + event.kind() + ": " + file);
+ }
+ files.add(file);
+ }
+ }
+ }
+ else if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
+ if (log.isDebugEnabled()) {
+ log.debug("Watch Event: " + event.kind() + ": context: "
+ + event.context());
+ }
+ if (event.context() != null && event.context() instanceof Path) {
+ files.addAll(walkDirectory((Path) event.context()));
+ }
+ else {
+ for (Path path : this.directory) {
+ files.addAll(walkDirectory(path));
+ }
+ }
+ }
+ else {
+ if (log.isDebugEnabled()) {
+ log.debug("Watch Event: " + event.kind() + ": context: "
+ + event.context());
+ }
+ }
+ }
+ key.reset();
+ key = this.watcher.poll();
+ }
+ return files;
+ }
+
+ private Set walkDirectory(Path directory) {
+ final Set walkedFiles = new LinkedHashSet();
+ try {
+ registerWatch(directory);
+ Files.walkFileTree(directory, new SimpleFileVisitor() {
+
+ @Override
+ public FileVisitResult preVisitDirectory(Path dir,
+ BasicFileAttributes attrs) throws IOException {
+ FileVisitResult fileVisitResult = super.preVisitDirectory(dir, attrs);
+ registerWatch(dir);
+ return fileVisitResult;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
+ throws IOException {
+ FileVisitResult fileVisitResult = super.visitFile(file, attrs);
+ walkedFiles.add(file.toFile());
+ return fileVisitResult;
+ }
+
+ });
+ }
+ catch (IOException e) {
+ log.error("Failed to walk directory: " + directory.toString(), e);
+ }
+ return walkedFiles;
+ }
+
+ private void registerWatch(Path dir) throws IOException {
+ if (log.isDebugEnabled()) {
+ log.debug("registering: " + dir + " for file creation events");
+ }
+ dir.register(this.watcher, StandardWatchEventKinds.ENTRY_CREATE,
+ StandardWatchEventKinds.ENTRY_MODIFY);
+ }
+
+}
diff --git a/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/GithubPropertyPathNotificationExtractor.java b/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/GithubPropertyPathNotificationExtractor.java
new file mode 100644
index 00000000..f117784a
--- /dev/null
+++ b/spring-cloud-config-monitor/src/main/java/org/springframework/cloud/config/monitor/GithubPropertyPathNotificationExtractor.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2015 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.config.monitor;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.util.MultiValueMap;
+
+/**
+ * @author Dave Syer
+ *
+ */
+@Order(Ordered.LOWEST_PRECEDENCE - 300)
+public class GithubPropertyPathNotificationExtractor
+ implements PropertyPathNotificationExtractor {
+
+ @Override
+ public PropertyPathNotification extract(MultiValueMap headers,
+ Map request) {
+ if ("push".equals(headers.getFirst("X-Github-Event"))) {
+ if (request.get("commits") instanceof Collection) {
+ Set paths = new HashSet<>();
+ @SuppressWarnings("unchecked")
+ Collection