Push events for config changes

Works from local repos or with explicit POST to /monitor with
path=<serviceId>, also supports webhooks from github and gitlab.
This commit is contained in:
Dave Syer
2015-09-08 08:54:24 +01:00
parent dc5097b188
commit 8d8e07a78e
19 changed files with 1339 additions and 9 deletions

View File

@@ -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

View File

@@ -22,13 +22,15 @@
</scm>
<properties>
<bintray.package>config</bintray.package>
<spring-cloud-bus.version>1.1.0.BUILD-SNAPSHOT</spring-cloud-bus.version>
<spring-cloud-commons.version>1.1.0.BUILD-SNAPSHOT</spring-cloud-commons.version>
</properties>
<modules>
<module>spring-cloud-config-client</module>
<module>spring-cloud-config-server</module>
<module>spring-cloud-config-monitor</module>
<module>spring-cloud-config-sample</module>
<module>spring-cloud-starter-config</module>
<module>spring-cloud-starter-config</module>
<module>docs</module>
</modules>
<dependencyManagement>
@@ -55,6 +57,11 @@
<artifactId>spring-cloud-config-server</artifactId>
<version>1.1.0.BUILD-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-monitor</artifactId>
<version>1.1.0.BUILD-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config</artifactId>
<version>1.1.0.BUILD-SNAPSHOT</version>
<relativePath>..</relativePath>
</parent>
<artifactId>spring-cloud-config-monitor</artifactId>
<name>spring-cloud-config-monitor</name>
<description>Spring Cloud Config Monitor</description>
<properties>
<main.basedir>${basedir}/../..</main.basedir>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-bus-parent</artifactId>
<version>${spring-cloud-bus.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-bus</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<!-- Only needed at compile time -->
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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<PropertyPathNotificationExtractor> extractors;
public CompositePropertyPathNotificationExtractor(
List<PropertyPathNotificationExtractor> 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<String, String> headers,
Map<String, Object> 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<String, String> headers,
Map<String, Object> request) {
Object object = request.get("path");
if (object instanceof String) {
return new PropertyPathNotification((String) object);
}
if (object instanceof Collection) {
@SuppressWarnings("unchecked")
Collection<String> collection = (Collection<String>) object;
return new PropertyPathNotification(collection.toArray(new String[0]));
}
return null;
}
}
}

View File

@@ -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<PropertyPathNotificationExtractor> extractors;
@Bean
public PropertyPathEndpoint propertyPathEndpoint() {
return new PropertyPathEndpoint(new CompositePropertyPathNotificationExtractor(this.extractors));
}
}

View File

@@ -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<Path> 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<String, String>(),
Collections.<String, Object> singletonMap("path",
file.getAbsolutePath()));
}
}
private Set<Path> 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<Path> 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<File> filesFromEvents() {
WatchKey key = this.watcher.poll();
Set<File> files = new LinkedHashSet<File>();
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<File> walkDirectory(Path directory) {
final Set<File> walkedFiles = new LinkedHashSet<File>();
try {
registerWatch(directory);
Files.walkFileTree(directory, new SimpleFileVisitor<Path>() {
@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);
}
}

View File

@@ -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<String, String> headers,
Map<String, Object> request) {
if ("push".equals(headers.getFirst("X-Github-Event"))) {
if (request.get("commits") instanceof Collection) {
Set<String> paths = new HashSet<>();
@SuppressWarnings("unchecked")
Collection<Map<String, Object>> commits = (Collection<Map<String, Object>>) request
.get("commits");
for (Map<String, Object> commit : commits) {
addAllPaths(paths, commit, "added");
addAllPaths(paths, commit, "removed");
addAllPaths(paths, commit, "modified");
}
if (!paths.isEmpty()) {
return new PropertyPathNotification(paths.toArray(new String[0]));
}
}
}
return null;
}
private void addAllPaths(Set<String> paths, Map<String, Object> commit, String name) {
@SuppressWarnings("unchecked")
Collection<String> files = (Collection<String>) commit.get(name);
if (files != null) {
paths.addAll(files);
}
}
}

View File

@@ -0,0 +1,47 @@
/*
* 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.Map;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.util.MultiValueMap;
/**
* @author Dave Syer
*
*/
@Order(Ordered.LOWEST_PRECEDENCE - 100)
public class GitlabPropertyPathNotificationExtractor
implements PropertyPathNotificationExtractor {
@Override
public PropertyPathNotification extract(MultiValueMap<String, String> headers,
Map<String, Object> request) {
if ("Push Event".equals(headers.getFirst("X-Gitlab-Event"))) {
if (request.get("commits") instanceof Collection) {
// Gitlab doesn't tell us the files that changed so this is a broadcast to
// all apps
return new PropertyPathNotification("application.yml");
}
}
return null;
}
}

View File

@@ -0,0 +1,128 @@
/*
* 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.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.beans.BeansException;
import org.springframework.cloud.bus.event.RefreshRemoteApplicationEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.http.MediaType;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.extern.apachecommons.CommonsLog;
/**
* HTTP endpoint for webhooks coming from repository providers.
*
* @author Dave Syer
*
*/
@RequiredArgsConstructor
@RestController
@RequestMapping("${spring.cloud.config.monitor.endpoint.path:}/monitor")
@CommonsLog
public class PropertyPathEndpoint
implements ApplicationEventPublisherAware, ApplicationContextAware {
private final PropertyPathNotificationExtractor extractor;
private ApplicationEventPublisher applicationEventPublisher;
private String contextId = UUID.randomUUID().toString();
@Override
public void setApplicationContext(ApplicationContext applicationContext)
throws BeansException {
this.contextId = applicationContext.getId();
}
@Override
public void setApplicationEventPublisher(
ApplicationEventPublisher applicationEventPublisher) {
this.applicationEventPublisher = applicationEventPublisher;
}
@RequestMapping(method = RequestMethod.POST)
public Set<String> notifyByPath(@RequestHeader MultiValueMap<String, String> headers,
@RequestBody Map<String, Object> request) {
PropertyPathNotification notification = this.extractor.extract(headers, request);
if (notification != null) {
Set<String> services = new HashSet<>();
for (String path : notification.getPaths()) {
services.addAll(guessServiceName(path));
}
if (this.applicationEventPublisher != null) {
for (String service : services) {
log.info("Refresh for: " + service);
this.applicationEventPublisher
.publishEvent(new RefreshRemoteApplicationEvent(this,
this.contextId, service));
}
return services;
}
}
return Collections.emptySet();
}
@RequestMapping(method = RequestMethod.POST, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public Set<String> notifyByForm(@RequestHeader MultiValueMap<String, String> headers, @RequestParam("path") List<String> request) {
Map<String, Object> map = new HashMap<>();
String key = "path";
map.put(key, request);
return notifyByPath(headers, map);
}
private Set<String> guessServiceName(String path) {
Set<String> services = new HashSet<>();
if (path != null) {
String stem = StringUtils
.stripFilenameExtension(StringUtils.getFilename(path));
// TODO: correlate with service registry, and if stem=="application"
// return all, otherwise return only the matching service
if (services.isEmpty()) {
if ("application".equals(stem)) {
services.add("*");
}
else {
services.add(stem);
}
}
}
return services;
}
}

View File

@@ -0,0 +1,38 @@
/*
* 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 lombok.Data;
import lombok.NoArgsConstructor;
/**
* Simple abstraction of a list of paths that changed in a repository.
*
* @author Dave Syer
*
*/
@Data
@NoArgsConstructor
public class PropertyPathNotification {
public PropertyPathNotification(String... paths) {
this.paths = paths;
}
private String[] paths;
}

View File

@@ -0,0 +1,36 @@
/*
* 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.Map;
import org.springframework.util.MultiValueMap;
/**
* Strategy for extracting a {@link PropertyPathNotification} from an incoming,
* unstructured request. Different providers of notifications have different payloads for
* their events, and different headers (e.g. HTTP headers for a webhook).
*
* @author Dave Syer
*
*/
public interface PropertyPathNotificationExtractor {
PropertyPathNotification extract(MultiValueMap<String, String> headers,
Map<String, Object> payload);
}

View File

@@ -0,0 +1,3 @@
# Autoconfiguration
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.cloud.config.monitor.EnvironmentMonitorAutoConfiguration

View File

@@ -0,0 +1,79 @@
/*
* 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 static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import org.junit.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpHeaders;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* @author Dave Syer
*
*/
public class CompositePropertyPathNotificationExtractorTests {
private CompositePropertyPathNotificationExtractor extractor = new CompositePropertyPathNotificationExtractor(
Arrays.asList(new GitlabPropertyPathNotificationExtractor(),
new GithubPropertyPathNotificationExtractor()));
private HttpHeaders headers = new HttpHeaders();
@Test
public void githubSample() throws Exception {
// See https://developer.github.com/v3/activity/events/types/#pushevent
Map<String, Object> value = new ObjectMapper().readValue(
new ClassPathResource("gitpush.json").getInputStream(),
new TypeReference<Map<String, Object>>() {
});
this.headers.set("X-Github-Event", "push");
PropertyPathNotification extracted = this.extractor.extract(this.headers, value);
assertNotNull(extracted);
assertEquals("README.md", extracted.getPaths()[0]);
}
@Test
public void gitlabDetected() throws Exception {
Map<String, Object> value = new ObjectMapper().readValue(
new ClassPathResource("gitlab.json").getInputStream(),
new TypeReference<Map<String, Object>>() {
});
this.headers.set("X-Gitlab-Event", "Push Event");
PropertyPathNotification extracted = this.extractor.extract(this.headers, value);
assertNotNull(extracted);
assertEquals("application.yml", extracted.getPaths()[0]);
}
@Test
public void fallback() throws Exception {
Map<String, Object> value = Collections.<String, Object> singletonMap("path",
"foo");
PropertyPathNotification extracted = this.extractor.extract(this.headers, value);
assertNotNull(extracted);
assertEquals("foo", extracted.getPaths()[0]);
}
}

View File

@@ -0,0 +1,77 @@
/*
* 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 static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import java.util.Map;
import org.junit.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpHeaders;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* @author Dave Syer
*
*/
public class GithubPropertyPathNotificationExtractorTests {
private GithubPropertyPathNotificationExtractor extractor = new GithubPropertyPathNotificationExtractor();
private HttpHeaders headers = new HttpHeaders();
@Test
public void githubSample() throws Exception {
// See https://developer.github.com/v3/activity/events/types/#pushevent
Map<String, Object> value = new ObjectMapper().readValue(
new ClassPathResource("gitpush.json").getInputStream(),
new TypeReference<Map<String, Object>>() {
});
this.headers.set("X-Github-Event", "push");
PropertyPathNotification extracted = this.extractor.extract(this.headers, value);
assertNotNull(extracted);
assertEquals("README.md", extracted.getPaths()[0]);
}
@Test
public void notAPushNotDetected() throws Exception {
Map<String, Object> value = new ObjectMapper().readValue(
new ClassPathResource("github.json").getInputStream(),
new TypeReference<Map<String, Object>>() {
});
this.headers.set("X-Github-Event", "issues");
PropertyPathNotification extracted = this.extractor.extract(this.headers, value);
assertNull(extracted);
}
@Test
public void gitlabNotDetected() throws Exception {
Map<String, Object> value = new ObjectMapper().readValue(
new ClassPathResource("gitlab.json").getInputStream(),
new TypeReference<Map<String, Object>>() {
});
this.headers.set("X-Github-Event", "push");
PropertyPathNotification extracted = this.extractor.extract(this.headers, value);
assertNull(extracted);
}
}

View File

@@ -0,0 +1,67 @@
/*
* 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 static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import java.util.Map;
import org.junit.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpHeaders;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* @author Dave Syer
*
*/
public class GitlabPropertyPathNotificationExtractorTests {
private GitlabPropertyPathNotificationExtractor extractor = new GitlabPropertyPathNotificationExtractor();
private HttpHeaders headers = new HttpHeaders();
@Test
public void pushEvent() throws Exception {
// See http://doc.gitlab.com/ee/web_hooks/web_hooks.html#push-events
Map<String, Object> value = new ObjectMapper().readValue(
new ClassPathResource("gitlab.json").getInputStream(),
new TypeReference<Map<String, Object>>() {
});
this.headers.set("X-Gitlab-Event", "Push Event");
PropertyPathNotification extracted = this.extractor.extract(this.headers, value);
assertNotNull(extracted);
assertEquals("application.yml", extracted.getPaths()[0]);
}
@Test
public void nonPushEventNotDetected() throws Exception {
// See http://doc.gitlab.com/ee/web_hooks/web_hooks.html#push-events
Map<String, Object> value = new ObjectMapper().readValue(
new ClassPathResource("gitlab.json").getInputStream(),
new TypeReference<Map<String, Object>>() {
});
this.headers.set("X-Gitlab-Event", "Issue Event");
PropertyPathNotification extracted = this.extractor.extract(this.headers, value);
assertNull(extracted);
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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 static org.junit.Assert.assertEquals;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.springframework.context.support.StaticApplicationContext;
import org.springframework.util.LinkedMultiValueMap;
/**
* @author Dave Syer
*
*/
public class PropertyPathEndpointTests {
private PropertyPathEndpoint endpoint = new PropertyPathEndpoint(
new CompositePropertyPathNotificationExtractor(
Collections.<PropertyPathNotificationExtractor> emptyList()));
@Before
public void init() {
StaticApplicationContext publisher = new StaticApplicationContext();
this.endpoint.setApplicationEventPublisher(publisher);
publisher.refresh();
}
@Test
public void testNotifyByForm() throws Exception {
assertEquals(0,
this.endpoint.notifyByForm(new LinkedMultiValueMap<String, String>(),
new ArrayList<String>()).size());
}
@Test
public void testNotifySeveral() throws Exception {
List<String> request = new ArrayList<String>();
request.add("/foo/bar.properties");
request.add("/application.properties");
assertEquals("[bar, *]",
this.endpoint
.notifyByForm(new LinkedMultiValueMap<String, String>(), request)
.toString());
}
@Test
public void testNotifyAll() throws Exception {
assertEquals("[*]", this.endpoint
.notifyByPath(new LinkedMultiValueMap<String, String>(), Collections
.<String, Object> singletonMap("path", "application.yml"))
.toString());
}
}

View File

@@ -0,0 +1,161 @@
{
"ref": "refs/heads/changes",
"before": "9049f1265b7d61be4a8904a9a27120d2064dab3b",
"after": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
"created": false,
"deleted": false,
"forced": false,
"base_ref": null,
"compare": "https://github.com/baxterthehacker/public-repo/compare/9049f1265b7d...0d1a26e67d8f",
"commits": [
{
"id": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
"distinct": true,
"message": "Update README.md",
"timestamp": "2015-05-05T19:40:15-04:00",
"url": "https://github.com/baxterthehacker/public-repo/commit/0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
"author": {
"name": "baxterthehacker",
"email": "baxterthehacker@users.noreply.github.com",
"username": "baxterthehacker"
},
"committer": {
"name": "baxterthehacker",
"email": "baxterthehacker@users.noreply.github.com",
"username": "baxterthehacker"
},
"added": [
],
"removed": [
],
"modified": [
"README.md"
]
}
],
"head_commit": {
"id": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
"distinct": true,
"message": "Update README.md",
"timestamp": "2015-05-05T19:40:15-04:00",
"url": "https://github.com/baxterthehacker/public-repo/commit/0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
"author": {
"name": "baxterthehacker",
"email": "baxterthehacker@users.noreply.github.com",
"username": "baxterthehacker"
},
"committer": {
"name": "baxterthehacker",
"email": "baxterthehacker@users.noreply.github.com",
"username": "baxterthehacker"
},
"added": [
],
"removed": [
],
"modified": [
"README.md"
]
},
"repository": {
"id": 35129377,
"name": "public-repo",
"full_name": "baxterthehacker/public-repo",
"owner": {
"name": "baxterthehacker",
"email": "baxterthehacker@users.noreply.github.com"
},
"private": false,
"html_url": "https://github.com/baxterthehacker/public-repo",
"description": "",
"fork": false,
"url": "https://github.com/baxterthehacker/public-repo",
"forks_url": "https://api.github.com/repos/baxterthehacker/public-repo/forks",
"keys_url": "https://api.github.com/repos/baxterthehacker/public-repo/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/baxterthehacker/public-repo/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/baxterthehacker/public-repo/teams",
"hooks_url": "https://api.github.com/repos/baxterthehacker/public-repo/hooks",
"issue_events_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/events{/number}",
"events_url": "https://api.github.com/repos/baxterthehacker/public-repo/events",
"assignees_url": "https://api.github.com/repos/baxterthehacker/public-repo/assignees{/user}",
"branches_url": "https://api.github.com/repos/baxterthehacker/public-repo/branches{/branch}",
"tags_url": "https://api.github.com/repos/baxterthehacker/public-repo/tags",
"blobs_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/baxterthehacker/public-repo/statuses/{sha}",
"languages_url": "https://api.github.com/repos/baxterthehacker/public-repo/languages",
"stargazers_url": "https://api.github.com/repos/baxterthehacker/public-repo/stargazers",
"contributors_url": "https://api.github.com/repos/baxterthehacker/public-repo/contributors",
"subscribers_url": "https://api.github.com/repos/baxterthehacker/public-repo/subscribers",
"subscription_url": "https://api.github.com/repos/baxterthehacker/public-repo/subscription",
"commits_url": "https://api.github.com/repos/baxterthehacker/public-repo/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/baxterthehacker/public-repo/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/baxterthehacker/public-repo/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/baxterthehacker/public-repo/contents/{+path}",
"compare_url": "https://api.github.com/repos/baxterthehacker/public-repo/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/baxterthehacker/public-repo/merges",
"archive_url": "https://api.github.com/repos/baxterthehacker/public-repo/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/baxterthehacker/public-repo/downloads",
"issues_url": "https://api.github.com/repos/baxterthehacker/public-repo/issues{/number}",
"pulls_url": "https://api.github.com/repos/baxterthehacker/public-repo/pulls{/number}",
"milestones_url": "https://api.github.com/repos/baxterthehacker/public-repo/milestones{/number}",
"notifications_url": "https://api.github.com/repos/baxterthehacker/public-repo/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/baxterthehacker/public-repo/labels{/name}",
"releases_url": "https://api.github.com/repos/baxterthehacker/public-repo/releases{/id}",
"created_at": 1430869212,
"updated_at": "2015-05-05T23:40:12Z",
"pushed_at": 1430869217,
"git_url": "git://github.com/baxterthehacker/public-repo.git",
"ssh_url": "git@github.com:baxterthehacker/public-repo.git",
"clone_url": "https://github.com/baxterthehacker/public-repo.git",
"svn_url": "https://github.com/baxterthehacker/public-repo",
"homepage": null,
"size": 0,
"stargazers_count": 0,
"watchers_count": 0,
"language": null,
"has_issues": true,
"has_downloads": true,
"has_wiki": true,
"has_pages": true,
"forks_count": 0,
"mirror_url": null,
"open_issues_count": 0,
"forks": 0,
"open_issues": 0,
"watchers": 0,
"default_branch": "master",
"stargazers": 0,
"master_branch": "master"
},
"pusher": {
"name": "baxterthehacker",
"email": "baxterthehacker@users.noreply.github.com"
},
"sender": {
"login": "baxterthehacker",
"id": 6752317,
"avatar_url": "https://avatars.githubusercontent.com/u/6752317?v=3",
"gravatar_id": "",
"url": "https://api.github.com/users/baxterthehacker",
"html_url": "https://github.com/baxterthehacker",
"followers_url": "https://api.github.com/users/baxterthehacker/followers",
"following_url": "https://api.github.com/users/baxterthehacker/following{/other_user}",
"gists_url": "https://api.github.com/users/baxterthehacker/gists{/gist_id}",
"starred_url": "https://api.github.com/users/baxterthehacker/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/baxterthehacker/subscriptions",
"organizations_url": "https://api.github.com/users/baxterthehacker/orgs",
"repos_url": "https://api.github.com/users/baxterthehacker/repos",
"events_url": "https://api.github.com/users/baxterthehacker/events{/privacy}",
"received_events_url": "https://api.github.com/users/baxterthehacker/received_events",
"type": "User",
"site_admin": false
}
}

View File

@@ -0,0 +1,42 @@
{
"object_kind": "push",
"before": "95790bf891e76fee5e1747ab589903a6a1f80f22",
"after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
"ref": "refs/heads/master",
"user_id": 4,
"user_name": "John Smith",
"user_email": "john@example.com",
"project_id": 15,
"repository": {
"name": "Diaspora",
"url": "git@example.com:mike/diasporadiaspora.git",
"description": "",
"homepage": "http://example.com/mike/diaspora",
"git_http_url":"http://example.com/mike/diaspora.git",
"git_ssh_url":"git@example.com:mike/diaspora.git",
"visibility_level":0
},
"commits": [
{
"id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327",
"message": "Update Catalan translation to e38cb41.",
"timestamp": "2011-12-12T14:27:31+02:00",
"url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327",
"author": {
"name": "Jordi Mallach",
"email": "jordi@softcatala.org"
}
},
{
"id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
"message": "fixed readme",
"timestamp": "2012-01-03T23:36:29+02:00",
"url": "http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
"author": {
"name": "GitLab dev user",
"email": "gitlabdev@dv6700.(none)"
}
}
],
"total_commits_count": 4
}

View File

@@ -76,7 +76,6 @@ public class EnvironmentController {
public EnvironmentController(EnvironmentRepository repository,
EnvironmentEncryptor environmentEncryptor) {
super();
this.repository = repository;
this.defaultLabel = repository.getDefaultLabel();
this.environmentEncryptor = environmentEncryptor;
@@ -85,7 +84,7 @@ public class EnvironmentController {
/**
* Flag to indicate that YAML documents which are not a map should be stripped of the
* "document" prefix that is added by Spring (to facilitate conversion to Properties).
*
*
* @param stripDocument the flag to set
*/
public void setStripDocumentFromYaml(boolean stripDocument) {
@@ -127,10 +126,11 @@ public class EnvironmentController {
@RequestMapping("/{label}/{name}-{profiles}.properties")
public ResponseEntity<String> labelledProperties(@PathVariable String name,
@PathVariable String profiles, @PathVariable String label) throws IOException {
@PathVariable String profiles, @PathVariable String label)
throws IOException {
validateNameAndProfiles(name, profiles);
Map<String, Object> properties = convertToProperties(labelled(name, profiles,
label));
Map<String, Object> properties = convertToProperties(
labelled(name, profiles, label));
return getSuccess(getPropertiesString(properties));
}
@@ -167,7 +167,8 @@ public class EnvironmentController {
return labelledYaml(name, profiles, this.defaultLabel);
}
@RequestMapping({ "/{label}/{name}-{profiles}.yml", "/{label}/{name}-{profiles}.yaml" })
@RequestMapping({ "/{label}/{name}-{profiles}.yml",
"/{label}/{name}-{profiles}.yaml" })
public ResponseEntity<String> labelledYaml(@PathVariable String name,
@PathVariable String profiles, @PathVariable String label) throws Exception {
validateNameAndProfiles(name, profiles);
@@ -301,7 +302,7 @@ public class EnvironmentController {
}
private void postProcessProperties(Map<String, Object> propertiesMap) {
for (Iterator<String> iter = propertiesMap.keySet().iterator(); iter.hasNext(); ) {
for (Iterator<String> iter = propertiesMap.keySet().iterator(); iter.hasNext();) {
String key = iter.next();
if (key.equals("spring.profiles")) {
iter.remove();