diff --git a/spring-cloud-localconfig-connector/README.md b/spring-cloud-localconfig-connector/README.md index f9788aa..1f04dc9 100644 --- a/spring-cloud-localconfig-connector/README.md +++ b/spring-cloud-localconfig-connector/README.md @@ -8,7 +8,7 @@ Pull requests for also inspecting environment variables are welcome. Property sources ---------------- -This connector first attempts to read the system properties generally and a property named +This connector first attempts to read the system properties generally and a system property named `spring.cloud.propertiesFile` specifically. If the system properties are not readable (the security manager denies `checkPropertiesAccess`), then they will be treated as empty. If a system property named `spring.cloud.propertiesFile` is found, that file will be loaded @@ -32,8 +32,7 @@ property sources in this order: - system properties The last definition of a specific service ID wins. The connector will log a message at -`INFO` to notify of service overrides for the same type of service and at `WARN` if you -override a service ID with a URI to a different type of service. +`WARN` if you override a service ID. Activating the connector ------------------------ diff --git a/spring-cloud-localconfig-connector/build.gradle b/spring-cloud-localconfig-connector/build.gradle index eb5f5b6..d020bde 100644 --- a/spring-cloud-localconfig-connector/build.gradle +++ b/spring-cloud-localconfig-connector/build.gradle @@ -2,4 +2,5 @@ description = 'Spring Cloud local-configuration connector' dependencies { compile project(':spring-cloud-core') + testCompile 'com.github.stefanbirkner:system-rules:1.5.0' } diff --git a/spring-cloud-localconfig-connector/src/main/java/org/springframework/cloud/localconfig/LocalConfigConnector.java b/spring-cloud-localconfig-connector/src/main/java/org/springframework/cloud/localconfig/LocalConfigConnector.java new file mode 100644 index 0000000..93bb25f --- /dev/null +++ b/spring-cloud-localconfig-connector/src/main/java/org/springframework/cloud/localconfig/LocalConfigConnector.java @@ -0,0 +1,187 @@ +package org.springframework.cloud.localconfig; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Properties; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +import org.springframework.cloud.AbstractCloudConnector; +import org.springframework.cloud.FallbackServiceInfoCreator; +import org.springframework.cloud.AbstractCloudConnector.KeyValuePair; +import org.springframework.cloud.app.ApplicationInstanceInfo; +import org.springframework.cloud.app.BasicApplicationInstanceInfo; +import org.springframework.cloud.service.BaseServiceInfo; +import org.springframework.cloud.service.FallbackBaseServiceInfoCreator; + +/** + * + * @author Christopher Smith + * + */ +public class LocalConfigConnector extends AbstractCloudConnector { + + private static final Logger logger = Logger.getLogger(LocalConfigConnector.class.getName()); + + /*--------------- String constants for property keys ---------------*/ + + public static final String PROPERTY_PREFIX = "spring.cloud."; + + public static final Pattern SERVICE_PROPERTY_PATTERN = Pattern.compile("\\A" + Pattern.quote(PROPERTY_PREFIX) + "(.+)" + "\\Z"); + + public static final String APP_ID_PROPERTY = PROPERTY_PREFIX + "appId"; + + public static final String PROPERTIES_FILE_PROPERTY = PROPERTY_PREFIX + "propertiesFile"; + + /** + * These properties configure the connector itself and aren't service definitions. + */ + public static final List META_PROPERTIES = Collections.unmodifiableList( + Arrays.asList(new String[] { APP_ID_PROPERTY, PROPERTIES_FILE_PROPERTY })); + + /*--------------- sources for service-definition properties ---------------*/ + + static Properties programmaticProperties = new Properties(); + + private Properties fileProperties = null; + + /*--------------- API implementation ---------------*/ + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public LocalConfigConnector() { + super((Class) LocalConfigServiceInfoCreator.class); + } + + /** + * Returns {@code true} if a property named {@code spring.cloud.appId} is present in any of the property sources. + * On the first call, attempts to load properties from a file specified in {@code spring.cloud.propertiesFile}. + */ + @Override + public boolean isInMatchingCloud() { + if (fileProperties == null) + readFileProperties(); + + return findProperty(APP_ID_PROPERTY) != null; + } + + @Override + public ApplicationInstanceInfo getApplicationInstanceInfo() { + return new BasicApplicationInstanceInfo(UUID.randomUUID().toString(), findProperty(APP_ID_PROPERTY), + Collections. emptyMap()); + } + + @Override + protected List getServicesData() { + LinkedHashMap propertySources = new LinkedHashMap(); + + propertySources.put("programmatic properties", programmaticProperties); + propertySources.put("properties from file", fileProperties); + try { + propertySources.put("system properties", System.getProperties()); + } catch (SecurityException e) { + logger.log(Level.WARNING, + "couldn't read system properties; no service definitions from system properties will be applied", e); + } + + return LocalConfigUtil.readServicesData(propertySources); + } + + @Override + protected FallbackServiceInfoCreator getFallbackServiceInfoCreator() { + return new FallbackBaseServiceInfoCreator(); + } + + /*--------------- methods for manipulating properties and sources ---------------*/ + + /** + * Adds properties to be scanned from the supplied {@link InputStream}, overwriting + * existing properties with the same name. Closes the stream after loading. + * + * @param propertiesInputStream + * a property list + * @throws IOException + * if the underlying load operation throws an exception + */ + public static void supplyProperties(final InputStream propertiesInputStream) throws IOException { + programmaticProperties.load(propertiesInputStream); + propertiesInputStream.close(); + } + + /** + * Checks for the presence of a supplied or system property named {@code spring.cloud.propertiesFile}. If the property + * is present, load its contents into {@link #fileProperties}. If there's a problem, log but continue. + */ + private void readFileProperties() { + fileProperties = new Properties(); + logger.fine("looking for a properties file"); + + String filename = null; + + filename = programmaticProperties.getProperty(PROPERTIES_FILE_PROPERTY); + + try { + filename = System.getProperty(PROPERTIES_FILE_PROPERTY, filename); + } catch (SecurityException e) { + logSystemReadException(PROPERTIES_FILE_PROPERTY, e); + return; + } + + if (filename == null) { + logger.info("did not find a system property " + PROPERTIES_FILE_PROPERTY); + return; + } + + logger.info("loading properties from file " + filename); + + try { + InputStream fis = openFile(filename); + fileProperties.load(fis); + } catch (IOException e) { + logger.log(Level.SEVERE, "exception while loading properties from file " + filename, e); + return; + } + + logger.info("properties loaded successfully"); + } + + /** + * Broken out into a separate method for mocking the filesystem. + * @param filename the file to open + * @return a {@code FileInputStream} to the file + * @throws IOException if opening the file throws + */ + InputStream openFile(String filename) throws IOException { + return new FileInputStream(filename); + } + + /** + * Look for a specific property in programmatically-supplied properties, properties from a file, + * or the system properties. Last source wins. + * + * @param key + * the property to look for + * @return the highest-priority value for the key, or {@code null} if the key is not found + */ + private String findProperty(String key) { + String value = programmaticProperties.getProperty(key); + value = fileProperties.getProperty(key, value); + try { + value = System.getProperty(key, value); + } catch (SecurityException e) { + logSystemReadException(key, e); + } + + return value; + } + + private static void logSystemReadException(String key, SecurityException e) { + logger.log(Level.WARNING, "couldn't read system property " + key, e); + } +} diff --git a/spring-cloud-localconfig-connector/src/main/java/org/springframework/cloud/localconfig/LocalConfigServiceInfoCreator.java b/spring-cloud-localconfig-connector/src/main/java/org/springframework/cloud/localconfig/LocalConfigServiceInfoCreator.java new file mode 100644 index 0000000..98a8669 --- /dev/null +++ b/spring-cloud-localconfig-connector/src/main/java/org/springframework/cloud/localconfig/LocalConfigServiceInfoCreator.java @@ -0,0 +1,11 @@ +package org.springframework.cloud.localconfig; + +import org.springframework.cloud.service.ServiceInfo; +import org.springframework.cloud.service.UriBasedServiceInfoCreator; + +public abstract class LocalConfigServiceInfoCreator extends UriBasedServiceInfoCreator { + + protected LocalConfigServiceInfoCreator(String uriScheme) { + super(uriScheme); + } +} diff --git a/spring-cloud-localconfig-connector/src/main/java/org/springframework/cloud/localconfig/LocalConfigUtil.java b/spring-cloud-localconfig-connector/src/main/java/org/springframework/cloud/localconfig/LocalConfigUtil.java new file mode 100644 index 0000000..bd4d9f3 --- /dev/null +++ b/spring-cloud-localconfig-connector/src/main/java/org/springframework/cloud/localconfig/LocalConfigUtil.java @@ -0,0 +1,81 @@ +package org.springframework.cloud.localconfig; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.logging.Logger; +import java.util.regex.Matcher; + +import org.springframework.cloud.AbstractCloudConnector.KeyValuePair; + +public final class LocalConfigUtil { + private static final Logger logger = Logger.getLogger(LocalConfigConnector.class.getName()); + + private LocalConfigUtil() { + } + + static List readServicesData(LinkedHashMap propertySources) { + // we'll turn this into KVPs to return but need to eliminate duplicates first + Map collectedServices = new HashMap(); + + // iterate over the property sources in order, extracting matching properties + for (Map.Entry propertySource : propertySources.entrySet()) { + logger.info("reading services from " + propertySource.getValue()); + Map services = readServices(propertySource.getValue()); + + // add each of the found services to the list, warning about duplicates + for (Map.Entry service : services.entrySet()) { + String oldUri = collectedServices.put(service.getKey(), service.getValue()); + if (oldUri == null) + logger.info("added service '" + service.getKey() + "' from " + propertySource.getKey()); + else + logger.warning("replaced service '" + service.getKey() + "' with new URI from " + propertySource.getKey()); + } + } + + // now we have a collated set of service IDs and URIs + List serviceData = new ArrayList(collectedServices.size()); + for (Map.Entry serviceInfo : collectedServices.entrySet()) { + serviceData.add(new KeyValuePair(serviceInfo.getKey(), serviceInfo.getValue())); + } + + return serviceData; + } + + /** + * Goes through a {@code Properties} object, finding all service definitions (properties + * prefixed with {@code spring.cloud.} but not in {@code META_PROPERTIES}) and collects {@code (id,URI)} pairs. + * + * @param properties + * the {@code Properties} object to read + * @return all of the service definitions found + */ + static Map readServices(Properties properties) { + Map services = new HashMap(); + + for (String propertyName : properties.stringPropertyNames()) { + if (LocalConfigConnector.META_PROPERTIES.contains(propertyName)) { + logger.finer("skipping meta property " + propertyName); + continue; + } + + Matcher m = LocalConfigConnector.SERVICE_PROPERTY_PATTERN.matcher(propertyName); + if (!m.matches()) { + logger.finest("skipping non-Spring-Cloud property " + propertyName); + continue; + } + + String serviceId = m.group(1); + String serviceUri = properties.getProperty(propertyName); + + // no URI here because they will contain passwords + logger.fine("found service URI for service " + serviceId); + services.put(serviceId, serviceUri); + } + + return services; + } +} diff --git a/spring-cloud-localconfig-connector/src/test/java/org/springframework/cloud/localconfig/LocalConfigConnectorTest.java b/spring-cloud-localconfig-connector/src/test/java/org/springframework/cloud/localconfig/LocalConfigConnectorTest.java new file mode 100644 index 0000000..394e851 --- /dev/null +++ b/spring-cloud-localconfig-connector/src/test/java/org/springframework/cloud/localconfig/LocalConfigConnectorTest.java @@ -0,0 +1,145 @@ +package org.springframework.cloud.localconfig; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Properties; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.contrib.java.lang.system.ClearSystemProperties; +import org.junit.contrib.java.lang.system.ProvideSystemProperty; +import org.springframework.cloud.AbstractCloudConnector.KeyValuePair; + +public class LocalConfigConnectorTest { + + static final Charset UTF_8 = Charset.forName("UTF-8"); + + public static final String APP_ID_1 = "appId1"; + public static final String APP_ID_1_PROPERTY = LocalConfigConnector.APP_ID_PROPERTY + ": " + APP_ID_1; + + public static final String APP_ID_2 = "appId2"; + public static final String APP_ID_2_PROPERTY = LocalConfigConnector.APP_ID_PROPERTY + ": " + APP_ID_2; + + public static final String PROPERTY_FILE_NAME = "propFile"; + public static final String PROPERTY_FILE_PROPERTY = LocalConfigConnector.PROPERTIES_FILE_PROPERTY + ": " + PROPERTY_FILE_NAME; + + public static class AppIdTest { + + private LocalConfigConnector connector; + + @Before + public void setup() { + connector = new LocalConfigConnector(); + } + + @After + public void clearProperties() { + LocalConfigConnector.programmaticProperties = new Properties(); + } + + @Rule + public final ClearSystemProperties NO_APP_ID_PROPERTY = new ClearSystemProperties(LocalConfigConnector.APP_ID_PROPERTY); + + @Test + public void testNoAppIdAnywhere() { + assertFalse(connector.isInMatchingCloud()); + } + + @Test + public void testProgrammaticAppId() throws IOException { + LocalConfigConnector.supplyProperties(new ByteArrayInputStream(APP_ID_1_PROPERTY.getBytes(UTF_8))); + assertTrue(connector.isInMatchingCloud()); + assertEquals(APP_ID_1, connector.getApplicationInstanceInfo().getAppId()); + } + + @Test + public void testProgrammaticAndFileAppIds() throws IOException { + LocalConfigConnector.supplyProperties(new ByteArrayInputStream(APP_ID_1_PROPERTY.getBytes(UTF_8))); + LocalConfigConnector.supplyProperties(new ByteArrayInputStream(PROPERTY_FILE_PROPERTY.getBytes(UTF_8))); + + LocalConfigConnector stubConnector = new LocalConfigConnector() { + @Override + InputStream openFile(String filename) throws IOException { + assertEquals(PROPERTY_FILE_NAME, filename); + return new ByteArrayInputStream(APP_ID_2_PROPERTY.getBytes(UTF_8)); + }; + }; + + assertTrue(stubConnector.isInMatchingCloud()); + assertEquals(APP_ID_2, stubConnector.getApplicationInstanceInfo().getAppId()); + } + + @Test + public void testProgrammaticFilenamePlusSystemAppId() throws IOException { + LocalConfigConnector.supplyProperties(new ByteArrayInputStream(PROPERTY_FILE_PROPERTY.getBytes(UTF_8))); + + LocalConfigConnector stubConnector = new LocalConfigConnector() { + @Override + InputStream openFile(String filename) throws IOException { + assertEquals(PROPERTY_FILE_NAME, filename); + return new ByteArrayInputStream(APP_ID_2_PROPERTY.getBytes(UTF_8)); + }; + }; + + System.setProperty(LocalConfigConnector.APP_ID_PROPERTY, "helloApp"); + assertTrue(stubConnector.isInMatchingCloud()); + assertEquals("helloApp", stubConnector.getApplicationInstanceInfo().getAppId()); + } + } + + private LocalConfigConnector connector; + + InputStream propertiesFile; + + @Before + public void setup() { + connector = new LocalConfigConnector(); + propertiesFile = LocalConfigConnectorTest.class.getClassLoader().getResourceAsStream("localconfig.properties"); + } + + @After + public void cleanup() throws IOException { + LocalConfigConnector.programmaticProperties = new Properties(); + propertiesFile.close(); + } + + @Test + public void testLoadFromFile() throws IOException { + LocalConfigConnector.supplyProperties(propertiesFile); + + assertTrue(connector.isInMatchingCloud()); + assertEquals("testApp", connector.getApplicationInstanceInfo().getAppId()); + + List services = connector.getServicesData(); + assertEquals(2, services.size()); + for (KeyValuePair service : services) + if ("foo".equals(service.getKey())) + assertEquals("bar", service.getValue()); + } + + @Rule + public ProvideSystemProperty BAZ_PROPERTY = new ProvideSystemProperty("spring.cloud.baz", "inline!"); + + @Test + public void testLoadFromInputStreamWithOverride() throws IOException { + LocalConfigConnector.supplyProperties(propertiesFile); + + assertTrue(connector.isInMatchingCloud()); + assertEquals("testApp", connector.getApplicationInstanceInfo().getAppId()); + + List services = connector.getServicesData(); + assertEquals(2, services.size()); + for(KeyValuePair service: services) + if("baz".equals(service.getKey())) + assertEquals("inline!", service.getValue()); + } +} diff --git a/spring-cloud-localconfig-connector/src/test/java/org/springframework/cloud/localconfig/LocalConfigUtilTest.java b/spring-cloud-localconfig-connector/src/test/java/org/springframework/cloud/localconfig/LocalConfigUtilTest.java new file mode 100644 index 0000000..10eaa58 --- /dev/null +++ b/spring-cloud-localconfig-connector/src/test/java/org/springframework/cloud/localconfig/LocalConfigUtilTest.java @@ -0,0 +1,74 @@ +package org.springframework.cloud.localconfig; + +import static org.junit.Assert.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.cloud.AbstractCloudConnector.KeyValuePair; + +public class LocalConfigUtilTest { + + private Properties first, second; + + private LinkedHashMap propertySources; + + @Before + public void initProperties(){ + first = new Properties(); + second = new Properties(); + + propertySources = new LinkedHashMap(); + propertySources.put("first", first); + propertySources.put("second", second); + } + + @Test + public void testPropertyParsing() { + first.setProperty("spring.cloud.appId", "should skip me because I'm meta"); + first.setProperty("spring.cloud.service1", "one"); + first.setProperty("spring.cloud.", "should skip me because I don't have an ID"); + first.setProperty("spring.cloud.service.two", "two"); + first.setProperty("foobar", "should skip me because I don't match the prefix"); + + Map services = LocalConfigUtil.readServices(first); + assertEquals(2, services.size()); + assertEquals("one", services.get("service1")); + assertEquals("two", services.get("service.two")); + } + + @Test + public void testCollation() { + first.setProperty("spring.cloud.first", "firstUri"); + second.setProperty("spring.cloud.second", "secondUri"); + + List serviceData = LocalConfigUtil.readServicesData(propertySources); + assertEquals(2, serviceData.size()); + boolean foundFirst = false; + + for(KeyValuePair kvp : serviceData) { + if(kvp.getKey().equals("first")) { + assertEquals("firstUri", kvp.getValue()); + foundFirst = true; + } + } + + assertTrue(foundFirst); + } + + @Test + public void testOverride() { + first.setProperty("spring.cloud.duplicate", "firstUri"); + second.setProperty("spring.cloud.duplicate", "secondUri"); + + List serviceData = LocalConfigUtil.readServicesData(propertySources); + assertEquals(1, serviceData.size()); + KeyValuePair kvp = serviceData.get(0); + assertEquals("duplicate", kvp.getKey()); + assertEquals("secondUri", kvp.getValue()); + } +} diff --git a/spring-cloud-localconfig-connector/src/test/resources/localconfig.properties b/spring-cloud-localconfig-connector/src/test/resources/localconfig.properties new file mode 100644 index 0000000..bcc84c3 --- /dev/null +++ b/spring-cloud-localconfig-connector/src/test/resources/localconfig.properties @@ -0,0 +1,3 @@ +spring.cloud.appId: testApp +spring.cloud.foo: bar +spring.cloud.baz: quux \ No newline at end of file