diff options
author | Jon Bratseth <bratseth@oath.com> | 2018-09-25 15:33:42 -0700 |
---|---|---|
committer | Jon Bratseth <bratseth@oath.com> | 2018-09-25 15:33:42 -0700 |
commit | 22b480874b1ca6400b8cd2640f678c210da07fa3 (patch) | |
tree | b60704a2560a4499a79d6f5de17bf3233b499fdb /config-application-package/src | |
parent | 0246064bbfb9657515f516e2fea12d593cd13016 (diff) |
Allow multiple regions and environments in a single attribute
Diffstat (limited to 'config-application-package/src')
8 files changed, 133 insertions, 88 deletions
diff --git a/config-application-package/src/main/java/com/yahoo/config/application/OverrideProcessor.java b/config-application-package/src/main/java/com/yahoo/config/application/OverrideProcessor.java index d68b36e063c..16accb368fd 100644 --- a/config-application-package/src/main/java/com/yahoo/config/application/OverrideProcessor.java +++ b/config-application-package/src/main/java/com/yahoo/config/application/OverrideProcessor.java @@ -1,6 +1,7 @@ // Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.config.application; +import com.google.common.collect.ImmutableSet; import com.yahoo.config.provision.Environment; import com.yahoo.config.provision.RegionName; import com.yahoo.log.LogLevel; @@ -10,8 +11,16 @@ import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import javax.xml.transform.TransformerException; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.logging.Logger; +import java.util.stream.Collectors; /** * Handles overrides in a XML document according to the rules defined for multi environment application packages. @@ -23,8 +32,7 @@ import java.util.logging.Logger; * 3. When multiple XML elements with the same name is specified (i.e. when specifying search or docproc chains), * the id attribute of the element is used together with the element name when applying directives * - * @author lulf - * @since 5.22 + * @author Ulf Lilleengen */ class OverrideProcessor implements PreProcessor { @@ -71,15 +79,13 @@ class OverrideProcessor implements PreProcessor { } private Context getParentContext(Element parent, Context context) { - Optional<Environment> environment = context.environment; - RegionName region = context.region; - if ( ! environment.isPresent()) { - environment = getEnvironment(parent); - } - if (region.isDefault()) { - region = getRegion(parent); - } - return Context.create(environment, region); + Set<Environment> environments = context.environments; + Set<RegionName> regions = context.regions; + if (environments.isEmpty()) + environments = getEnvironments(parent); + if (regions.isEmpty()) + regions = getRegions(parent); + return Context.create(environments, regions); } /** @@ -100,13 +106,13 @@ class OverrideProcessor implements PreProcessor { */ private void checkConsistentInheritance(List<Element> children, Context context) { for (Element child : children) { - Optional<Environment> env = getEnvironment(child); - RegionName reg = getRegion(child); - if (env.isPresent() && context.environment.isPresent() && !env.equals(context.environment)) { - throw new IllegalArgumentException("Environment in child (" + env.get() + ") differs from that inherited from parent (" + context.environment + ") at " + child); + Set<Environment> environments = getEnvironments(child); + Set<RegionName> regions = getRegions(child); + if ( ! environments.isEmpty() && ! context.environments.isEmpty() && !environments.equals(context.environments)) { + throw new IllegalArgumentException("Environments in child (" + environments + ") differs from that inherited from parent (" + context.environments + ") at " + child); } - if (!reg.isDefault() && !context.region.isDefault() && !reg.equals(context.region)) { - throw new IllegalArgumentException("Region in child (" + reg + ") differs from that inherited from parent (" + context.region + ") at " + child); + if ( ! regions.isEmpty() && ! context.regions.isEmpty() && ! regions.equals(context.regions)) { + throw new IllegalArgumentException("Regions in child (" + regions + ") differs from that inherited from parent (" + context.regions + ") at " + child); } } } @@ -118,22 +124,24 @@ class OverrideProcessor implements PreProcessor { Iterator<Element> elemIt = children.iterator(); while (elemIt.hasNext()) { Element child = elemIt.next(); - if ( ! matches(getEnvironment(child), getRegion(child))) { + if ( ! matches(getEnvironments(child), getRegions(child))) { parent.removeChild(child); elemIt.remove(); } } } - private boolean matches(Optional<Environment> elementEnvironment, RegionName elementRegion) { - if (elementEnvironment.isPresent()) { // match environment - if (! environment.equals(elementEnvironment.get())) return false; + private boolean matches(Set<Environment> elementEnvironments, Set<RegionName> elementRegions) { + if ( ! elementEnvironments.isEmpty()) { // match environment + if ( ! elementEnvironments.contains(environment)) return false; } - if ( ! elementRegion.isDefault()) { // match region - if ( ! region.equals(elementRegion)) return false; - // match region but no environment in prod only to avoid a region attribute overriding capacity policies outside prod - if ( ! elementEnvironment.isPresent() && ! environment.equals(Environment.prod)) return false; + if ( ! elementRegions.isEmpty()) { // match region + // match region in prod only + if ( environment.equals(Environment.prod) && ! elementRegions.contains(region)) return false; + + // explicit region implies prod + if ( ! environment.equals(Environment.prod) && elementEnvironments.isEmpty() ) return false; } return true; @@ -174,11 +182,11 @@ class OverrideProcessor implements PreProcessor { private int getNumberOfOverrides(Element child, Context context) { int currentMatch = 0; - Optional<Environment> elementEnvironment = hasEnvironment(child) ? getEnvironment(child) : context.environment; - RegionName elementRegion = hasRegion(child) ? getRegion(child) : context.region; - if (elementEnvironment.isPresent() && elementEnvironment.get().equals(environment)) + Set<Environment> elementEnvironments = hasEnvironment(child) ? getEnvironments(child) : context.environments; + Set<RegionName> elementRegions = hasRegion(child) ? getRegions(child) : context.regions; + if ( ! elementEnvironments.isEmpty() && elementEnvironments.contains(environment)) currentMatch++; - if ( ! elementRegion.isDefault() && elementRegion.equals(region)) + if ( ! elementRegions.isEmpty() && elementRegions.contains(region)) currentMatch++; return currentMatch; } @@ -220,20 +228,16 @@ class OverrideProcessor implements PreProcessor { return element.hasAttributeNS(XmlPreProcessor.deployNamespaceUri, ATTR_ENV); } - private Optional<Environment> getEnvironment(Element element) { + private Set<Environment> getEnvironments(Element element) { String env = element.getAttributeNS(XmlPreProcessor.deployNamespaceUri, ATTR_ENV); - if (env == null || env.isEmpty()) { - return Optional.empty(); - } - return Optional.of(Environment.from(env)); + if (env == null || env.isEmpty()) return Collections.emptySet(); + return Arrays.stream(env.split(" ")).map(Environment::from).collect(Collectors.toSet()); } - private RegionName getRegion(Element element) { + private Set<RegionName> getRegions(Element element) { String reg = element.getAttributeNS(XmlPreProcessor.deployNamespaceUri, ATTR_REG); - if (reg == null || reg.isEmpty()) { - return RegionName.defaultName(); - } - return RegionName.from(reg); + if (reg == null || reg.isEmpty()) return Collections.emptySet(); + return Arrays.stream(reg.split(" ")).map(RegionName::from).collect(Collectors.toSet()); } private Map<String, List<Element>> elementsByTagNameAndId(List<Element> children) { @@ -287,21 +291,21 @@ class OverrideProcessor implements PreProcessor { */ private static final class Context { - final Optional<Environment> environment; + final ImmutableSet<Environment> environments; - final RegionName region; + final ImmutableSet<RegionName> regions; - private Context(Optional<Environment> environment, RegionName region) { - this.environment = environment; - this.region = region; + private Context(Set<Environment> environments, Set<RegionName> regions) { + this.environments = ImmutableSet.copyOf(environments); + this.regions = ImmutableSet.copyOf(regions); } static Context empty() { - return new Context(Optional.empty(), RegionName.defaultName()); + return new Context(ImmutableSet.of(), ImmutableSet.of()); } - public static Context create(Optional<Environment> environment, RegionName region) { - return new Context(environment, region); + public static Context create(Set<Environment> environments, Set<RegionName> regions) { + return new Context(environments, regions); } } diff --git a/config-application-package/src/main/java/com/yahoo/config/application/PreProcessor.java b/config-application-package/src/main/java/com/yahoo/config/application/PreProcessor.java index d331c4432bc..f207a07d3be 100644 --- a/config-application-package/src/main/java/com/yahoo/config/application/PreProcessor.java +++ b/config-application-package/src/main/java/com/yahoo/config/application/PreProcessor.java @@ -9,9 +9,10 @@ import java.io.IOException; /** * Performs pre-processing of XML document and returns new document that has been processed. * - * @author lulf - * @since 5.21 + * @author Ulf Lilleengen */ public interface PreProcessor { - public Document process(Document input) throws IOException, TransformerException; + + Document process(Document input) throws IOException, TransformerException; + } diff --git a/config-application-package/src/main/java/com/yahoo/config/application/PropertiesProcessor.java b/config-application-package/src/main/java/com/yahoo/config/application/PropertiesProcessor.java index 6e45460a4c4..65120c3677c 100644 --- a/config-application-package/src/main/java/com/yahoo/config/application/PropertiesProcessor.java +++ b/config-application-package/src/main/java/com/yahoo/config/application/PropertiesProcessor.java @@ -16,7 +16,6 @@ import java.util.logging.Logger; * Handles getting properties from services.xml and replacing references to properties with their real values * * @author hmusum - * @since 5.22 */ class PropertiesProcessor implements PreProcessor { private final static Logger log = Logger.getLogger(PropertiesProcessor.class.getName()); @@ -82,8 +81,8 @@ class PropertiesProcessor implements PreProcessor { } private String replaceValue(String propertyValue) { - /* Use a list with keys sorted by length (longest key first) - Needed for replacing values where you have overlapping keys */ + // Use a list with keys sorted by length (longest key first) + // Needed for replacing values where you have overlapping keys ArrayList<String> keys = new ArrayList<>(properties.keySet()); Collections.sort(keys, Collections.reverseOrder(Comparator.comparing(String::length))); diff --git a/config-application-package/src/main/java/com/yahoo/config/application/XmlPreProcessor.java b/config-application-package/src/main/java/com/yahoo/config/application/XmlPreProcessor.java index ceef0a6730e..0bb160319c0 100644 --- a/config-application-package/src/main/java/com/yahoo/config/application/XmlPreProcessor.java +++ b/config-application-package/src/main/java/com/yahoo/config/application/XmlPreProcessor.java @@ -22,7 +22,6 @@ import java.util.List; * and create a new Document which is based on the supplied environment and region * * @author hmusum - * @since 5.22 */ public class XmlPreProcessor { @@ -51,7 +50,7 @@ public class XmlPreProcessor { public Document run() throws ParserConfigurationException, IOException, SAXException, TransformerException { DocumentBuilder docBuilder = Xml.getPreprocessDocumentBuilder(); - final Document document = docBuilder.parse(new InputSource(xmlInput)); + Document document = docBuilder.parse(new InputSource(xmlInput)); return execute(document); } diff --git a/config-application-package/src/main/java/com/yahoo/config/model/application/provider/FilesApplicationPackage.java b/config-application-package/src/main/java/com/yahoo/config/model/application/provider/FilesApplicationPackage.java index 7ca9bcf48f3..1724bad765f 100644 --- a/config-application-package/src/main/java/com/yahoo/config/model/application/provider/FilesApplicationPackage.java +++ b/config-application-package/src/main/java/com/yahoo/config/model/application/provider/FilesApplicationPackage.java @@ -305,6 +305,7 @@ public class FilesApplicationPackage implements ApplicationPackage { /** * Verify that two sets of search definitions are disjoint (TODO: everything except error message is very generic). + * * @param fileSds Set of search definitions from file * @param bundleSds Set of search definitions from bundles */ diff --git a/config-application-package/src/test/java/com/yahoo/config/application/IncludeProcessorTest.java b/config-application-package/src/test/java/com/yahoo/config/application/IncludeProcessorTest.java index d3c2b672ee5..a456924673d 100644 --- a/config-application-package/src/test/java/com/yahoo/config/application/IncludeProcessorTest.java +++ b/config-application-package/src/test/java/com/yahoo/config/application/IncludeProcessorTest.java @@ -13,44 +13,48 @@ import java.io.*; import java.nio.file.NoSuchFileException; /** - * @author lulf - * @since 5.22 + * @author Ulf Lilleengen */ public class IncludeProcessorTest { @Test - public void testInclude() throws IOException, SAXException, XMLStreamException, ParserConfigurationException, TransformerException { + public void testInclude() throws IOException, SAXException, ParserConfigurationException, TransformerException { File app = new File("src/test/resources/multienvapp"); DocumentBuilder docBuilder = Xml.getPreprocessDocumentBuilder(); - String expected = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?><services xmlns:deploy=\"vespa\" xmlns:preprocess=\"properties\" version=\"1.0\">\n" + + String expected = + "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>" + + "<services xmlns:deploy=\"vespa\" xmlns:preprocess=\"properties\" version=\"1.0\">\n" + " <preprocess:properties>\n" + " <qrs.port>4099</qrs.port>\n" + " <qrs.port>5000</qrs.port>\n" + " </preprocess:properties>\n" + " <preprocess:properties deploy:environment='prod'>\n" + " <qrs.port deploy:region='us-west'>5001</qrs.port>" + - " <qrs.port deploy:region='us-east'>5002</qrs.port>" + + " <qrs.port deploy:region='us-east us-central'>5002</qrs.port>" + " </preprocess:properties>\n" + " <admin version=\"2.0\">\n" + " <adminserver hostalias=\"node0\"/>\n" + " </admin>\n" + - " <admin deploy:environment=\"prod\" version=\"2.0\">\n" + + " <admin deploy:environment=\"staging prod\" deploy:region=\"us-east us-central\" version=\"2.0\">\n" + " <adminserver hostalias=\"node1\"/>\n" + " </admin>\n" + " <content id=\"foo\" version=\"1.0\">\n" + " <redundancy>1</redundancy><documents>\n" + " <document mode=\"index\" type=\"music.sd\"/>\n" + - "</documents><nodes>\n" + + " </documents><nodes>\n" + " <node distribution-key=\"0\" hostalias=\"node0\"/>\n" + - "</nodes><nodes deploy:environment=\"prod\">\n" + + " </nodes>" + + " <nodes deploy:environment=\"prod\">\n" + " <node distribution-key=\"0\" hostalias=\"node0\"/>\n" + " <node distribution-key=\"1\" hostalias=\"node1\"/>\n" + - "</nodes><nodes deploy:environment=\"prod\" deploy:region=\"us-west\">\n" + + " </nodes>" + + " <nodes deploy:environment=\"prod\" deploy:region=\"us-west\">\n" + " <node distribution-key=\"0\" hostalias=\"node0\"/>\n" + " <node distribution-key=\"1\" hostalias=\"node1\"/>\n" + " <node distribution-key=\"2\" hostalias=\"node2\"/>\n" + - "</nodes></content>\n" + + " </nodes>" + + "</content>\n" + "<jdisc id=\"stateless\" version=\"1.0\">\n" + " <search deploy:environment=\"prod\">\n" + " <chain id=\"common\">\n" + @@ -68,7 +72,7 @@ public class IncludeProcessorTest { " </nodes>\n" + "</jdisc></services>"; - Document doc = (new IncludeProcessor(app)).process(docBuilder.parse(Xml.getServices(app))); + Document doc = new IncludeProcessor(app).process(docBuilder.parse(Xml.getServices(app))); // System.out.println(Xml.documentAsString(doc)); TestBase.assertDocument(expected, doc); } @@ -77,7 +81,7 @@ public class IncludeProcessorTest { public void testRequiredIncludeIsDefault() throws ParserConfigurationException, IOException, SAXException, TransformerException { File app = new File("src/test/resources/multienvapp_failrequired"); DocumentBuilder docBuilder = Xml.getPreprocessDocumentBuilder(); - (new IncludeProcessor(app)).process(docBuilder.parse(Xml.getServices(app))); + new IncludeProcessor(app).process(docBuilder.parse(Xml.getServices(app))); } } diff --git a/config-application-package/src/test/java/com/yahoo/config/application/XmlPreprocessorTest.java b/config-application-package/src/test/java/com/yahoo/config/application/XmlPreprocessorTest.java index b20437bc259..3827fe2ad42 100644 --- a/config-application-package/src/test/java/com/yahoo/config/application/XmlPreprocessorTest.java +++ b/config-application-package/src/test/java/com/yahoo/config/application/XmlPreprocessorTest.java @@ -23,8 +23,10 @@ public class XmlPreprocessorTest { private static final File services = new File(appDir, "services.xml"); @Test - public void testPreProcessing() throws IOException, SAXException, XMLStreamException, ParserConfigurationException, TransformerException { - String expectedDev = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?><services xmlns:deploy=\"vespa\" xmlns:preprocess=\"properties\" version=\"1.0\">\n" + + public void testPreProcessing() throws IOException, SAXException, ParserConfigurationException, TransformerException { + String expectedDev = + "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>" + + "<services xmlns:deploy=\"vespa\" xmlns:preprocess=\"properties\" version=\"1.0\">\n" + " <admin version=\"2.0\">\n" + " <adminserver hostalias=\"node0\"/>\n" + " </admin>\n" + @@ -46,14 +48,40 @@ public class XmlPreprocessorTest { " </nodes>\n" + " </jdisc>\n" + "</services>"; + TestBase.assertDocument(expectedDev, new XmlPreProcessor(appDir, services, Environment.dev, RegionName.from("default")).run()); - Document docDev = (new XmlPreProcessor(appDir, services, Environment.dev, RegionName.from("default")).run()); - TestBase.assertDocument(expectedDev, docDev); - + String expectedStaging = + "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>" + + "<services xmlns:deploy=\"vespa\" xmlns:preprocess=\"properties\" version=\"1.0\">\n" + + " <admin version=\"2.0\">\n" + + " <adminserver hostalias=\"node1\"/>\n" + // Difference from dev: node1 + " </admin>\n" + + " <content id=\"foo\" version=\"1.0\">\n" + + " <redundancy>1</redundancy>\n" + + " <documents>\n" + + " <document mode=\"index\" type=\"music.sd\"/>\n" + + " </documents>\n" + + " <nodes>\n" + + " <node distribution-key=\"0\" hostalias=\"node0\"/>\n" + + " </nodes>\n" + + " </content>\n" + + " <jdisc id=\"stateless\" version=\"1.0\">\n" + + " <search/>\n" + + " <component bundle=\"foobundle\" class=\"MyFoo\" id=\"foo\"/>\n" + + "" + // Difference from dev: no TestBar + " <nodes>\n" + + " <node hostalias=\"node0\" baseport=\"5000\"/>\n" + + " </nodes>\n" + + " </jdisc>\n" + + "</services>"; + // System.out.println(Xml.documentAsString(new XmlPreProcessor(appDir, services, Environment.staging, RegionName.from("default")).run())); + TestBase.assertDocument(expectedStaging, new XmlPreProcessor(appDir, services, Environment.staging, RegionName.from("default")).run()); - String expectedUsWest = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?><services xmlns:deploy=\"vespa\" xmlns:preprocess=\"properties\" version=\"1.0\">\n" + + String expectedUsWest = + "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>" + + "<services xmlns:deploy=\"vespa\" xmlns:preprocess=\"properties\" version=\"1.0\">\n" + " <admin version=\"2.0\">\n" + - " <adminserver hostalias=\"node1\"/>\n" + + " <adminserver hostalias=\"node0\"/>\n" + " </admin>\n" + " <content id=\"foo\" version=\"1.0\">\n" + " <redundancy>1</redundancy>\n" + @@ -81,12 +109,11 @@ public class XmlPreprocessorTest { " </nodes>\n" + " </jdisc>\n" + "</services>"; + TestBase.assertDocument(expectedUsWest, new XmlPreProcessor(appDir, services, Environment.prod, RegionName.from("us-west")).run()); - Document docUsWest = (new XmlPreProcessor(appDir, services, Environment.prod, RegionName.from("us-west"))).run(); - // System.out.println(Xml.documentAsString(docUsWest)); - TestBase.assertDocument(expectedUsWest, docUsWest); - - String expectedUsEast = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?><services xmlns:deploy=\"vespa\" xmlns:preprocess=\"properties\" version=\"1.0\">\n" + + String expectedUsEastAndCentral = + "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>" + + "<services xmlns:deploy=\"vespa\" xmlns:preprocess=\"properties\" version=\"1.0\">\n" + " <admin version=\"2.0\">\n" + " <adminserver hostalias=\"node1\"/>\n" + " </admin>\n" + @@ -115,14 +142,16 @@ public class XmlPreprocessorTest { " </nodes>\n" + " </jdisc>\n" + "</services>"; - - Document docUsEast = (new XmlPreProcessor(appDir, services, Environment.prod, RegionName.from("us-east"))).run(); - TestBase.assertDocument(expectedUsEast, docUsEast); + TestBase.assertDocument(expectedUsEastAndCentral, + new XmlPreProcessor(appDir, services, Environment.prod, RegionName.from("us-east")).run()); + TestBase.assertDocument(expectedUsEastAndCentral, + new XmlPreProcessor(appDir, services, Environment.prod, RegionName.from("us-central")).run()); } @Test - public void testPropertiesWithOverlappingNames() throws IOException, SAXException, XMLStreamException, ParserConfigurationException, TransformerException { - String input = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>" + + public void testPropertiesWithOverlappingNames() throws IOException, SAXException, ParserConfigurationException, TransformerException { + String input = + "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>" + "<services xmlns:deploy=\"vespa\" xmlns:preprocess=\"properties\" version=\"1.0\">" + " <preprocess:properties>" + " <sherpa.host>gamma-usnc1.dht.yahoo.com</sherpa.host>" + @@ -146,7 +175,8 @@ public class XmlPreprocessorTest { " </admin>" + "</services>"; - String expectedProd = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>" + + String expectedProd = + "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>" + "<services xmlns:deploy=\"vespa\" xmlns:preprocess=\"properties\" version=\"1.0\">" + " <config name='a'>" + " <a>36000</a>" + diff --git a/config-application-package/src/test/resources/multienvapp/services.xml b/config-application-package/src/test/resources/multienvapp/services.xml index 3d4a2087c57..d0f43f0b025 100644 --- a/config-application-package/src/test/resources/multienvapp/services.xml +++ b/config-application-package/src/test/resources/multienvapp/services.xml @@ -1,5 +1,6 @@ <!-- Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. --> <services version='1.0' xmlns:deploy="vespa" xmlns:preprocess="properties"> + <preprocess:properties> <qrs.port>4099</qrs.port> <qrs.port>5000</qrs.port> @@ -7,17 +8,23 @@ <preprocess:properties deploy:environment='prod'> <qrs.port deploy:region='us-west'>5001</qrs.port> - <qrs.port deploy:region='us-east'>5002</qrs.port> + <qrs.port deploy:region='us-east us-central'>5002</qrs.port> </preprocess:properties> + <admin version='2.0'> <adminserver hostalias='node0'/> </admin> - <admin version='2.0' deploy:environment='prod'> + + <admin version='2.0' deploy:environment='staging prod' deploy:region='us-east us-central'> <adminserver hostalias='node1'/> </admin> + <preprocess:include file='jdisc.xml'/> + <content version='1.0' id='foo'> <preprocess:include file='content/content_foo.xml'/> </content> + <preprocess:include file='doesnotexist.xml' required='false' /> + </services> |