Implement VaultEnvironmentRepository.

This is an alternative to VCS or file based repository implementations.

All properties are stored ecrypted in vault, regardless of whether or
not that actaully need to be encrypted.

Supports properties style (this.is.a.property) or vault nested
properties.

Adds a watch to client and server, if enabled, the client will long poll
the server and wait for changes rather than relying on a message broker
to send change events.

Adds a server side watch for consul keys, this is useful when vault is
backed by consul (and currently the only use case, since there is not a
consul repository implementation).

Closes gh-397
This commit is contained in:
Spencer Gibb
2016-05-04 17:00:31 -06:00
parent 0850de9737
commit 753db0ed22
32 changed files with 742 additions and 692 deletions

View File

@@ -16,6 +16,7 @@
package org.springframework.cloud.config.server.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.cloud.config.server.encryption.EnvironmentEncryptor;
import org.springframework.cloud.config.server.environment.EnvironmentController;
@@ -42,9 +43,6 @@ public class ConfigServerMvcConfiguration extends WebMvcConfigurerAdapter {
@Autowired
private EnvironmentRepository repository;
@Autowired
private ResourceRepository resources;
@Autowired
private ConfigServerProperties server;
@@ -69,8 +67,9 @@ public class ConfigServerMvcConfiguration extends WebMvcConfigurerAdapter {
}
@Bean
public ResourceController resourceController() {
ResourceController controller = new ResourceController(this.resources,
@ConditionalOnBean(ResourceRepository.class)
public ResourceController resourceController(ResourceRepository repository) {
ResourceController controller = new ResourceController(repository,
encrypted());
return controller;
}

View File

@@ -15,14 +15,19 @@
*/
package org.springframework.cloud.config.server.config;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.config.server.environment.ConsulEnvironmentWatch;
import org.springframework.cloud.config.server.environment.EnvironmentRepository;
import org.springframework.cloud.config.server.environment.EnvironmentWatch;
import org.springframework.cloud.config.server.environment.MultipleJGitEnvironmentRepository;
import org.springframework.cloud.config.server.environment.NativeEnvironmentRepository;
import org.springframework.cloud.config.server.environment.SvnKitEnvironmentRepository;
import org.springframework.cloud.config.server.environment.VaultEnvironmentRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@@ -96,4 +101,32 @@ public class EnvironmentRepositoryConfiguration {
}
}
@Configuration
@Profile("vault")
protected static class VaultConfiguration {
@Bean
public EnvironmentRepository environmentRepository(HttpServletRequest request, EnvironmentWatch watch) {
return new VaultEnvironmentRepository(request, watch);
}
}
@Configuration
@ConditionalOnProperty(value = "spring.cloud.config.server.consul.watch.enabled")
protected static class ConsulEnvironmentWatchConfiguration {
@Bean
public EnvironmentWatch environmentWatch() {
return new ConsulEnvironmentWatch();
}
}
@Configuration
@ConditionalOnMissingBean(EnvironmentWatch.class)
protected static class DefaultEnvironmentWatch {
@Bean
public EnvironmentWatch environmentWatch() {
return new EnvironmentWatch.Default();
}
}
}

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.cloud.config.server.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.config.server.environment.SearchPathLocator;
@@ -33,6 +34,7 @@ import org.springframework.context.annotation.Configuration;
public class ResourceRepositoryConfiguration {
@Bean
@ConditionalOnBean(SearchPathLocator.class)
public ResourceRepository resourceRepository(SearchPathLocator service) {
return new GenericResourceRepository(service);
}

View File

@@ -57,8 +57,7 @@ public class CipherEnvironmentEncryptor implements EnvironmentEncryptor {
}
private Environment decrypt(Environment environment, TextEncryptorLocator encryptor) {
Environment result = new Environment(environment.getName(),
environment.getProfiles(), environment.getLabel(), environment.getVersion());
Environment result = new Environment(environment);
for (PropertySource source : environment.getPropertySources()) {
Map<Object, Object> map = new LinkedHashMap<Object, Object>(
source.getSource());

View File

@@ -0,0 +1,126 @@
package org.springframework.cloud.config.server.environment;
import java.util.ArrayList;
import java.util.List;
import javax.validation.constraints.NotNull;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
/**
* @author Spencer Gibb
*/
@ConfigurationProperties("spring.cloud.config.server.consul.watch")
public class ConsulEnvironmentWatch implements EnvironmentWatch {
public static final String CONSUL_INDEX = "X-Consul-Index";
public static final String CONSUL_TOKEN = "X-Consul-Token";
private static Log LOG = LogFactory.getLog(ConsulEnvironmentWatch.class);
private static final String WATCH_URL = "{scheme}://{host}:{port}/v1/kv/{path}?keys&recurse&wait={wait}&index={index}";
public static final ParameterizedTypeReference<List<String>> RESPONSE_TYPE = new ParameterizedTypeReference<List<String>>() {
};
private RestTemplate restTemplate = new RestTemplate();
/** Consul agent scheme. Defaults to 'http'. */
@NotNull
private String scheme = "http";
/** Consul agent hostname. Defaults to 'localhost'. */
@NotNull
private String host = "localhost";
/** Consul agent port. Defaults to '8500'. */
@NotNull
private int port = 8500;
/** Path to watch in consul key/value store. */
@NotNull
private String path;
/** Consul wait value (eg, 3m or 30s). */
@NotNull
private String wait = "3m";
/** Consul ACL token. */
private String token;
@Override
public String watch(String state) {
ArrayList<String> params = new ArrayList<>();
params.add(this.scheme);
params.add(this.host);
params.add(String.valueOf(this.port));
params.add(this.path);
params.add(this.wait);
params.add (StringUtils.hasText(state) ? state : "");
try {
HttpHeaders headers = new HttpHeaders();
if (StringUtils.hasText(token)) {
headers.add(CONSUL_TOKEN, token);
}
HttpEntity<Object> request = new HttpEntity<>(headers);
ResponseEntity<List<String>> response = this.restTemplate.exchange(WATCH_URL,
HttpMethod.GET, request, RESPONSE_TYPE, params.toArray());
if (response.getStatusCode().is2xxSuccessful()) {
String consulIndex = response.getHeaders().getFirst(CONSUL_INDEX);
return consulIndex;
}
}
catch (HttpStatusCodeException e) {
if (!e.getStatusCode().equals(HttpStatus.NOT_FOUND)) {
LOG.error("Unable to watch consul path " + this.path, e);
return null;
}
}
// TODO: error handling?
return null;
}
public void setRestTemplate(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public void setScheme(String scheme) {
this.scheme = scheme;
}
public void setHost(String host) {
this.host = host;
}
public void setPort(int port) {
this.port = port;
}
public void setPath(String path) {
this.path = path;
if (this.path.startsWith("/")) {
this.path = this.path.substring(1);
}
}
public void setWait(String wait) {
this.wait = wait;
}
public void setToken(String token) {
this.token = token;
}
}

View File

@@ -25,8 +25,7 @@ import org.springframework.cloud.config.environment.PropertySource;
public class EnvironmentCleaner {
public Environment clean(Environment value, String workingDir, String uri) {
Environment result = new Environment(value.getName(), value.getProfiles(),
value.getLabel(), value.getVersion());
Environment result = new Environment(value);
for (PropertySource source : value.getPropertySources()) {
String name = source.getName().replace(workingDir, "");
name = name.replace("applicationConfig: [", "");

View File

@@ -277,7 +277,7 @@ public class EnvironmentController {
String[] keys = StringUtils.delimitedListToStringArray(stem, ".");
for (int i = 0; i < keys.length - 1; i++) {
if (current.get(keys[i]) == null) {
LinkedHashMap<String, Object> map = new LinkedHashMap<String, Object>();
LinkedHashMap<String, Object> map = new LinkedHashMap<>();
current.put(keys[i], map);
current = map;
}
@@ -290,7 +290,7 @@ public class EnvironmentController {
}
String name = keys[keys.length - 1];
if (current.get(name) == null) {
current.put(name, new ArrayList<Object>());
current.put(name, new ArrayList<>());
}
@SuppressWarnings("unchecked")
List<Object> value = (List<Object>) current.get(name);

View File

@@ -64,7 +64,7 @@ public class EnvironmentEncryptorEnvironmentRepository implements EnvironmentRep
* @param overrides the overrides to set
*/
public void setOverrides(Map<String, String> overrides) {
this.overrides = new HashMap<String, String>(overrides);
this.overrides = new HashMap<>(overrides);
for (String key : overrides.keySet()) {
if (overrides.get(key).contains("\\{")) {
this.overrides.put(key, overrides.get(key).replace("\\{", "{"));

View File

@@ -0,0 +1,16 @@
package org.springframework.cloud.config.server.environment;
/**
* @author Spencer Gibb
*/
public interface EnvironmentWatch {
String watch(String state);
class Default implements EnvironmentWatch {
@Override
public String watch(String state) {
return null;
}
}
}

View File

@@ -178,7 +178,7 @@ public class NativeEnvironmentRepository
protected Environment clean(Environment value) {
Environment result = new Environment(value.getName(), value.getProfiles(),
value.getLabel(), this.version);
value.getLabel(), this.version, value.getState());
for (PropertySource source : value.getPropertySources()) {
String name = source.getName();
if (this.environment.getPropertySources().contains(name)) {

View File

@@ -60,7 +60,7 @@ public class PassthruEnvironmentRepository implements EnvironmentRepository {
@Override
public Environment findOne(String application, String env, String label) {
Environment result = new Environment(application, StringUtils.commaDelimitedListToStringArray(env), label, null);
Environment result = new Environment(application, StringUtils.commaDelimitedListToStringArray(env), label, null, null);
for (org.springframework.core.env.PropertySource<?> source : this.environment.getPropertySources()) {
String name = source.getName();
if (!this.standardSources.contains(name) && source instanceof MapPropertySource) {

View File

@@ -0,0 +1,247 @@
package org.springframework.cloud.config.server.environment;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import com.fasterxml.jackson.annotation.JsonRawValue;
import com.fasterxml.jackson.databind.JsonNode;
import org.hibernate.validator.constraints.NotEmpty;
import org.hibernate.validator.constraints.Range;
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.config.environment.Environment;
import org.springframework.cloud.config.environment.PropertySource;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.annotation.JsonProperty;
import static org.springframework.cloud.config.client.ConfigClientProperties.STATE_HEADER;
import static org.springframework.cloud.config.client.ConfigClientProperties.TOKEN_HEADER;
/**
* @author Spencer Gibb
*/
@ConfigurationProperties("spring.cloud.config.server.vault")
public class VaultEnvironmentRepository implements EnvironmentRepository {
public static final String VAULT_TOKEN = "X-Vault-Token";
/** Vault host. Defaults to 127.0.0.1. */
@NotEmpty
private String host = "127.0.0.1";
/** Vault port. Defaults to 8200. */
@Range(min = 1, max = 65535)
private int port = 8200;
/** Vault scheme. Defaults to http. */
private String scheme = "http";
/** Vault backend. Defaults to secret. */
@NotEmpty
private String backend = "secret";
/** The key in vault shared by all applications. Defaults to application. Set to empty to disable. */
private String defaultKey = "application";
/** Vault profile separator. Defaults to comma. */
@NotEmpty
private String profileSeparator = ",";
private RestTemplate rest = new RestTemplate();
//TODO: move to watchState:String on findOne?
private HttpServletRequest request;
private EnvironmentWatch watch;
public VaultEnvironmentRepository(HttpServletRequest request, EnvironmentWatch watch) {
this.request = request;
this.watch = watch;
}
@Override
public Environment findOne(String application, String profile, String label) {
String state = request.getHeader(STATE_HEADER);
String newState = this.watch.watch(state);
String[] profiles = StringUtils.commaDelimitedListToStringArray(profile);
List<String> scrubbedProfiles = scrubProfiles(profiles);
List<String> keys = findKeys(application, scrubbedProfiles);
Environment environment = new Environment(application, profiles, label, null, newState);
for (String key : keys) {
// read raw 'data' key from vault
String data = read(key);
// data is in json format of which, yaml is a superset, so parse
final YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
yaml.setResources(new ByteArrayResource(data.getBytes()));
Properties properties = yaml.getObject();
if (!properties.isEmpty()) {
environment.add(new PropertySource("vault:"+key, properties));
}
}
return environment;
}
private List<String> findKeys(String application, List<String> profiles) {
List<String> keys = new ArrayList<>();
if (StringUtils.hasText(this.defaultKey)) {
keys.add(this.defaultKey);
addProfiles(keys, this.defaultKey, profiles);
}
keys.add(application);
addProfiles(keys, application, profiles);
Collections.reverse(keys);
return keys;
}
private List<String> scrubProfiles(String[] profiles) {
List<String> scrubbedProfiles = new ArrayList<>(Arrays.asList(profiles));
if (scrubbedProfiles.contains("default")) {
scrubbedProfiles.remove("default");
}
return scrubbedProfiles;
}
private void addProfiles(List<String> contexts, String baseContext,
List<String> profiles) {
for (String profile : profiles) {
contexts.add(baseContext + this.profileSeparator + profile);
}
}
String read(String key) {
String url = String.format("%s://%s:%s/v1/{backend}/{key}", this.scheme,
this.host, this.port);
HttpHeaders headers = new HttpHeaders();
String token = request.getHeader(TOKEN_HEADER);
if (!StringUtils.hasLength(token)) {
throw new IllegalArgumentException("Missing required header: "+TOKEN_HEADER);
}
headers.add(VAULT_TOKEN, token);
try {
ResponseEntity<VaultResponse> response = this.rest.exchange(url,
HttpMethod.GET, new HttpEntity<>(headers), VaultResponse.class,
this.backend, key);
HttpStatus status = response.getStatusCode();
if (status == HttpStatus.OK) {
return response.getBody().getData();
}
}
catch (HttpStatusCodeException e) {
if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
return null;
}
throw e;
}
return null;
}
public void setHost(String host) {
this.host = host;
}
public void setPort(int port) {
this.port = port;
}
public void setScheme(String scheme) {
this.scheme = scheme;
}
public void setBackend(String backend) {
this.backend = backend;
}
public void setDefaultKey(String defaultKey) {
this.defaultKey = defaultKey;
}
public void setProfileSeparator(String profileSeparator) {
this.profileSeparator = profileSeparator;
}
static class VaultResponse {
private String auth;
private Object data;
@JsonProperty("lease_duration")
private long leaseDuration;
@JsonProperty("lease_id")
private String leaseId;
private boolean renewable;
public VaultResponse() {
}
public String getAuth() {
return auth;
}
public void setAuth(String auth) {
this.auth = auth;
}
@JsonRawValue
public String getData() {
return data == null ? null : data.toString();
}
public void setData(JsonNode data) {
this.data = data;
}
public long getLeaseDuration() {
return leaseDuration;
}
public void setLeaseDuration(long leaseDuration) {
this.leaseDuration = leaseDuration;
}
public String getLeaseId() {
return leaseId;
}
public void setLeaseId(String leaseId) {
this.leaseId = leaseId;
}
public boolean isRenewable() {
return renewable;
}
public void setRenewable(boolean renewable) {
this.renewable = renewable;
}
}
}

View File

@@ -0,0 +1,14 @@
package org.springframework.cloud.config.server.environment;
import org.junit.Test;
/**
* @author Spencer Gibb
*/
public class VaultEnvironmentRepositoryTests {
@Test
public void testFindOne() {
//TODO: implement testFindOne
}
}