diff options
55 files changed, 2743 insertions, 881 deletions
diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java index a09e23bbe9e..c5452ecdde3 100644 --- a/abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java +++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java @@ -192,7 +192,7 @@ public class AbiCheck extends AbstractMojo { } else { Map<String, JavaClassSignature> abiSpec = readSpec(specFile); if (!compareSignatures(abiSpec, signatures, getLog())) { - throw new MojoFailureException("ABI spec mismatch"); + throw new MojoFailureException("ABI spec mismatch. To update run 'mvn package -Dabicheck.writeSpec'"); } } } catch (IOException e) { diff --git a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/Certificates.java b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/Certificates.java index 447b6efb09b..a4cf54063ec 100644 --- a/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/Certificates.java +++ b/athenz-identity-provider-service/src/main/java/com/yahoo/vespa/hosted/ca/Certificates.java @@ -43,7 +43,7 @@ public class Certificates { SHA256_WITH_ECDSA, X509CertificateBuilder.generateRandomSerialNumber()); for (var san : csr.getSubjectAlternativeNames()) { - builder = builder.addSubjectAlternativeName(san.getValue()); + builder = builder.addSubjectAlternativeName(san.decode()); } return builder.build(); } diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificateTester.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificateTester.java index 4946de93f6d..130a4ec5e66 100644 --- a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificateTester.java +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificateTester.java @@ -47,13 +47,16 @@ public class CertificateTester { return createCsr(null); } - public static Pkcs10Csr createCsr(String dnsName) { + public static Pkcs10Csr createCsr(String dnsName, String... ipAddresses) { X500Principal subject = new X500Principal("CN=subject"); KeyPair keyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC, 256); var builder = Pkcs10CsrBuilder.fromKeypair(subject, keyPair, SignatureAlgorithm.SHA512_WITH_ECDSA); if (dnsName != null) { builder = builder.addSubjectAlternativeName(SubjectAlternativeName.Type.DNS_NAME, dnsName); } + for (var ipAddress : ipAddresses) { + builder = builder.addSubjectAlternativeName(SubjectAlternativeName.Type.IP_ADDRESS, ipAddress); + } return builder.build(); } diff --git a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificatesTest.java b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificatesTest.java index 80940dcd02c..fa86979656d 100644 --- a/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificatesTest.java +++ b/athenz-identity-provider-service/src/test/java/com/yahoo/vespa/hosted/ca/CertificatesTest.java @@ -40,13 +40,18 @@ public class CertificatesTest { public void add_san_from_csr() throws Exception { var certificates = new Certificates(new ManualClock()); var dnsName = "host.example.com"; - var csr = CertificateTester.createCsr(dnsName); + var ip = "192.0.2.42"; + var csr = CertificateTester.createCsr(dnsName, ip); var certificate = certificates.create(csr, caCertificate, keyPair.getPrivate()); assertNotNull(certificate.getSubjectAlternativeNames()); - assertEquals(1, certificate.getSubjectAlternativeNames().size()); + assertEquals(2, certificate.getSubjectAlternativeNames().size()); + + var subjectAlternativeNames = List.copyOf(certificate.getSubjectAlternativeNames()); assertEquals(List.of(SubjectAlternativeName.Type.DNS_NAME.getTag(), dnsName), - certificate.getSubjectAlternativeNames().iterator().next()); + subjectAlternativeNames.get(0)); + assertEquals(List.of(SubjectAlternativeName.Type.IP_ADDRESS.getTag(), ip), + subjectAlternativeNames.get(1)); } } diff --git a/config-model-api/abi-spec.json b/config-model-api/abi-spec.json index 32c9e433157..315b03c301a 100644 --- a/config-model-api/abi-spec.json +++ b/config-model-api/abi-spec.json @@ -187,6 +187,35 @@ ], "fields": [] }, + "com.yahoo.config.application.api.DeploymentInstanceSpec": { + "superClass": "com.yahoo.config.application.api.DeploymentSpec$Step", + "interfaces": [], + "attributes": [ + "public" + ], + "methods": [ + "public void <init>(com.yahoo.config.provision.InstanceName, java.util.List, com.yahoo.config.application.api.DeploymentSpec$UpgradePolicy, java.util.List, java.util.Optional, java.util.Optional, java.util.Optional, com.yahoo.config.application.api.Notifications, java.util.List)", + "public com.yahoo.config.provision.InstanceName name()", + "public java.time.Duration delay()", + "public java.util.List steps()", + "public com.yahoo.config.application.api.DeploymentSpec$UpgradePolicy upgradePolicy()", + "public java.util.List changeBlocker()", + "public java.util.Optional globalServiceId()", + "public boolean canUpgradeAt(java.time.Instant)", + "public boolean canChangeRevisionAt(java.time.Instant)", + "public java.util.List zones()", + "public boolean deploysTo(com.yahoo.config.provision.Environment, java.util.Optional)", + "public java.util.Optional athenzDomain()", + "public java.util.Optional athenzService(com.yahoo.config.provision.Environment, com.yahoo.config.provision.RegionName)", + "public com.yahoo.config.application.api.Notifications notifications()", + "public java.util.List endpoints()", + "public boolean includes(com.yahoo.config.provision.Environment, java.util.Optional)", + "public boolean equals(java.lang.Object)", + "public int hashCode()", + "public java.lang.String toString()" + ], + "fields": [] + }, "com.yahoo.config.application.api.DeploymentSpec$ChangeBlocker": { "superClass": "java.lang.Object", "interfaces": [], @@ -197,7 +226,8 @@ "public void <init>(boolean, boolean, com.yahoo.config.application.api.TimeWindow)", "public boolean blocksRevisions()", "public boolean blocksVersions()", - "public com.yahoo.config.application.api.TimeWindow window()" + "public com.yahoo.config.application.api.TimeWindow window()", + "public java.lang.String toString()" ], "fields": [] }, @@ -234,7 +264,9 @@ "methods": [ "public void <init>(java.time.Duration)", "public java.time.Duration duration()", - "public boolean deploysTo(com.yahoo.config.provision.Environment, java.util.Optional)" + "public java.time.Duration delay()", + "public boolean deploysTo(com.yahoo.config.provision.Environment, java.util.Optional)", + "public java.lang.String toString()" ], "fields": [] }, @@ -247,9 +279,11 @@ "methods": [ "public void <init>(java.util.List)", "public java.util.List zones()", + "public java.util.List steps()", "public boolean deploysTo(com.yahoo.config.provision.Environment, java.util.Optional)", "public boolean equals(java.lang.Object)", - "public int hashCode()" + "public int hashCode()", + "public java.lang.String toString()" ], "fields": [] }, @@ -264,7 +298,9 @@ "public void <init>()", "public final boolean deploysTo(com.yahoo.config.provision.Environment)", "public abstract boolean deploysTo(com.yahoo.config.provision.Environment, java.util.Optional)", - "public java.util.List zones()" + "public java.util.List zones()", + "public java.time.Duration delay()", + "public java.util.List steps()" ], "fields": [] }, @@ -293,6 +329,7 @@ "public" ], "methods": [ + "public void <init>(java.util.List, java.util.Optional, java.lang.String)", "public void <init>(java.util.Optional, com.yahoo.config.application.api.DeploymentSpec$UpgradePolicy, java.util.Optional, java.util.List, java.util.List, java.lang.String, java.util.Optional, java.util.Optional, com.yahoo.config.application.api.Notifications, java.util.List)", "public java.util.Optional globalServiceId()", "public com.yahoo.config.application.api.DeploymentSpec$UpgradePolicy upgradePolicy()", @@ -302,16 +339,21 @@ "public java.util.List changeBlocker()", "public java.util.List steps()", "public java.util.List zones()", + "public java.util.Optional athenzDomain()", + "public java.util.Optional athenzService(com.yahoo.config.provision.Environment, com.yahoo.config.provision.RegionName)", "public com.yahoo.config.application.api.Notifications notifications()", "public java.util.List endpoints()", "public java.lang.String xmlForm()", "public boolean includes(com.yahoo.config.provision.Environment, java.util.Optional)", + "public com.yahoo.config.application.api.DeploymentInstanceSpec instance(java.lang.String)", + "public com.yahoo.config.application.api.DeploymentInstanceSpec instance(com.yahoo.config.provision.InstanceName)", + "public com.yahoo.config.application.api.DeploymentInstanceSpec requireInstance(java.lang.String)", + "public com.yahoo.config.application.api.DeploymentInstanceSpec requireInstance(com.yahoo.config.provision.InstanceName)", + "public java.util.List instances()", "public static com.yahoo.config.application.api.DeploymentSpec fromXml(java.io.Reader)", "public static com.yahoo.config.application.api.DeploymentSpec fromXml(java.lang.String)", "public static com.yahoo.config.application.api.DeploymentSpec fromXml(java.lang.String, boolean)", "public static java.lang.String toMessageString(java.lang.Throwable)", - "public java.util.Optional athenzDomain()", - "public java.util.Optional athenzService(com.yahoo.config.provision.Environment, com.yahoo.config.provision.RegionName)", "public boolean equals(java.lang.Object)", "public int hashCode()", "public static void main(java.lang.String[])" diff --git a/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentInstanceSpec.java b/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentInstanceSpec.java new file mode 100644 index 00000000000..df611d66b87 --- /dev/null +++ b/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentInstanceSpec.java @@ -0,0 +1,254 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.config.application.api; + +import com.yahoo.config.provision.AthenzDomain; +import com.yahoo.config.provision.AthenzService; +import com.yahoo.config.provision.Environment; +import com.yahoo.config.provision.InstanceName; +import com.yahoo.config.provision.RegionName; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * The deployment spec for an application instance + * + * @author bratseth + */ +public class DeploymentInstanceSpec extends DeploymentSpec.Step { + + /** The name of the instance this step deploys */ + private final InstanceName name; + + private final List<DeploymentSpec.Step> steps; + private final DeploymentSpec.UpgradePolicy upgradePolicy; + private final List<DeploymentSpec.ChangeBlocker> changeBlockers; + private final Optional<String> globalServiceId; + private final Optional<AthenzDomain> athenzDomain; + private final Optional<AthenzService> athenzService; + private final Notifications notifications; + private final List<Endpoint> endpoints; + + public DeploymentInstanceSpec(InstanceName name, + List<DeploymentSpec.Step> steps, + DeploymentSpec.UpgradePolicy upgradePolicy, + List<DeploymentSpec.ChangeBlocker> changeBlockers, + Optional<String> globalServiceId, + Optional<AthenzDomain> athenzDomain, + Optional<AthenzService> athenzService, + Notifications notifications, + List<Endpoint> endpoints) { + this.name = name; + this.steps = steps; + this.upgradePolicy = upgradePolicy; + this.changeBlockers = changeBlockers; + this.globalServiceId = globalServiceId; + this.athenzDomain = athenzDomain; + this.athenzService = athenzService; + this.notifications = notifications; + this.endpoints = List.copyOf(validateEndpoints(endpoints, this.steps)); + validateZones(this.steps); + validateEndpoints(this.steps, globalServiceId, this.endpoints); + validateAthenz(); + } + + public InstanceName name() { return name; } + + /** Throw an IllegalArgumentException if any production zone is declared multiple times */ + private void validateZones(List<DeploymentSpec.Step> steps) { + Set<DeploymentSpec.DeclaredZone> zones = new HashSet<>(); + + for (DeploymentSpec.Step step : steps) + for (DeploymentSpec.DeclaredZone zone : step.zones()) + ensureUnique(zone, zones); + } + + private void ensureUnique(DeploymentSpec.DeclaredZone zone, Set<DeploymentSpec.DeclaredZone> zones) { + if ( ! zones.add(zone)) + throw new IllegalArgumentException(zone + " is listed twice in deployment.xml"); + } + + /** Validates the endpoints and makes sure default values are respected */ + private List<Endpoint> validateEndpoints(List<Endpoint> endpoints, List<DeploymentSpec.Step> steps) { + Objects.requireNonNull(endpoints, "Missing endpoints parameter"); + + var productionRegions = steps.stream() + .filter(step -> step.deploysTo(Environment.prod)) + .flatMap(step -> step.zones().stream()) + .flatMap(zone -> zone.region().stream()) + .map(RegionName::value) + .collect(Collectors.toSet()); + + var rebuiltEndpointsList = new ArrayList<Endpoint>(); + + for (var endpoint : endpoints) { + if (endpoint.regions().isEmpty()) { + var rebuiltEndpoint = endpoint.withRegions(productionRegions); + rebuiltEndpointsList.add(rebuiltEndpoint); + } else { + rebuiltEndpointsList.add(endpoint); + } + } + + return List.copyOf(rebuiltEndpointsList); + } + + /** Throw an IllegalArgumentException if an endpoint refers to a region that is not declared in 'prod' */ + private void validateEndpoints(List<DeploymentSpec.Step> steps, Optional<String> globalServiceId, List<Endpoint> endpoints) { + if (globalServiceId.isPresent() && ! endpoints.isEmpty()) { + throw new IllegalArgumentException("Providing both 'endpoints' and 'global-service-id'. Use only 'endpoints'."); + } + + var stepZones = steps.stream() + .flatMap(s -> s.zones().stream()) + .flatMap(z -> z.region().stream()) + .collect(Collectors.toSet()); + + for (var endpoint : endpoints){ + for (var endpointRegion : endpoint.regions()) { + if (! stepZones.contains(endpointRegion)) { + throw new IllegalArgumentException("Region used in endpoint that is not declared in 'prod': " + endpointRegion); + } + } + } + } + + /** + * Throw an IllegalArgumentException if Athenz configuration violates: + * domain not configured -> no zone can configure service + * domain configured -> all zones must configure service + */ + private void validateAthenz() { + // If athenz domain is not set, athenz service cannot be set on any level + if (athenzDomain.isEmpty()) { + for (DeploymentSpec.DeclaredZone zone : zones()) { + if(zone.athenzService().isPresent()) { + throw new IllegalArgumentException("Athenz service configured for zone: " + zone + ", but Athenz domain is not configured"); + } + } + // if athenz domain is not set, athenz service must be set implicitly or directly on all zones. + } else if (athenzService.isEmpty()) { + for (DeploymentSpec.DeclaredZone zone : zones()) { + if (zone.athenzService().isEmpty()) { + throw new IllegalArgumentException("Athenz domain is configured, but Athenz service not configured for zone: " + zone); + } + } + } + } + + @Override + public Duration delay() { + return Duration.ofSeconds(steps.stream().mapToLong(step -> (step.delay().getSeconds())).sum()); + } + + /** Returns the deployment steps inside this in the order they will be performed */ + @Override + public List<DeploymentSpec.Step> steps() { return steps; } + + /** Returns the upgrade policy of this, which is defaultPolicy if none is specified */ + public DeploymentSpec.UpgradePolicy upgradePolicy() { return upgradePolicy; } + + /** Returns time windows where upgrades are disallowed for these instances */ + public List<DeploymentSpec.ChangeBlocker> changeBlocker() { return changeBlockers; } + + /** Returns the ID of the service to expose through global routing, if present */ + public Optional<String> globalServiceId() { return globalServiceId; } + + /** Returns whether the instances in this step can upgrade at the given instant */ + public boolean canUpgradeAt(Instant instant) { + return changeBlockers.stream().filter(block -> block.blocksVersions()) + .noneMatch(block -> block.window().includes(instant)); + } + + /** Returns whether an application revision change for these instances can occur at the given instant */ + public boolean canChangeRevisionAt(Instant instant) { + return changeBlockers.stream().filter(block -> block.blocksRevisions()) + .noneMatch(block -> block.window().includes(instant)); + } + + /** Returns all the deployment steps which are zones in the order they are declared */ + public List<DeploymentSpec.DeclaredZone> zones() { + return steps.stream() + .flatMap(step -> step.zones().stream()) + .collect(Collectors.toList()); + } + + /** Returns whether this deployment spec specifies the given zone, either implicitly or explicitly */ + @Override + public boolean deploysTo(Environment environment, Optional<RegionName> region) { + for (DeploymentSpec.Step step : steps) + if (step.deploysTo(environment, region)) return true; + return false; + } + + /** Returns the athenz domain if configured */ + public Optional<AthenzDomain> athenzDomain() { return athenzDomain; } + + /** Returns the athenz service for environment/region if configured */ + public Optional<AthenzService> athenzService(Environment environment, RegionName region) { + AthenzService athenzService = zones().stream() + .filter(zone -> zone.deploysTo(environment, Optional.of(region))) + .findFirst() + .flatMap(DeploymentSpec.DeclaredZone::athenzService) + .orElse(this.athenzService.orElse(null)); + return Optional.ofNullable(athenzService); + } + + /** Returns the notification configuration of these instances */ + public Notifications notifications() { return notifications; } + + /** Returns the rotations configuration of these instances */ + public List<Endpoint> endpoints() { return endpoints; } + + /** Returns whether this instances deployment specifies the given zone, either implicitly or explicitly */ + public boolean includes(Environment environment, Optional<RegionName> region) { + for (DeploymentSpec.Step step : steps) + if (step.deploysTo(environment, region)) return true; + return false; + } + + DeploymentInstanceSpec withSteps(List<DeploymentSpec.Step> steps) { + return new DeploymentInstanceSpec(name, + steps, + upgradePolicy, + changeBlockers, + globalServiceId, + athenzDomain, + athenzService, + notifications, + endpoints); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DeploymentInstanceSpec other = (DeploymentInstanceSpec) o; + return globalServiceId.equals(other.globalServiceId) && + upgradePolicy == other.upgradePolicy && + changeBlockers.equals(other.changeBlockers) && + steps.equals(other.steps) && + athenzDomain.equals(other.athenzDomain) && + athenzService.equals(other.athenzService) && + notifications.equals(other.notifications) && + endpoints.equals(other.endpoints); + } + + @Override + public int hashCode() { + return Objects.hash(globalServiceId, upgradePolicy, changeBlockers, steps, athenzDomain, athenzService, notifications, endpoints); + } + + @Override + public String toString() { + return "instance '" + name + "'"; + } + +} diff --git a/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentSpec.java b/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentSpec.java index efe75d191b8..71af050174b 100644 --- a/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentSpec.java +++ b/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentSpec.java @@ -5,6 +5,7 @@ import com.yahoo.config.application.api.xml.DeploymentSpecXmlReader; import com.yahoo.config.provision.AthenzDomain; import com.yahoo.config.provision.AthenzService; import com.yahoo.config.provision.Environment; +import com.yahoo.config.provision.InstanceName; import com.yahoo.config.provision.RegionName; import java.io.BufferedReader; @@ -14,11 +15,9 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.Set; import java.util.stream.Collectors; /** @@ -46,218 +45,195 @@ public class DeploymentSpec { Optional.empty(), Notifications.none(), List.of()); - - private final Optional<String> globalServiceId; - private final UpgradePolicy upgradePolicy; - private final Optional<Integer> majorVersion; - private final List<ChangeBlocker> changeBlockers; + private final List<Step> steps; + private final Optional<Integer> majorVersion; private final String xmlForm; - private final Optional<AthenzDomain> athenzDomain; - private final Optional<AthenzService> athenzService; - private final Notifications notifications; - private final List<Endpoint> endpoints; - - public DeploymentSpec(Optional<String> globalServiceId, UpgradePolicy upgradePolicy, Optional<Integer> majorVersion, - List<ChangeBlocker> changeBlockers, List<Step> steps, String xmlForm, - Optional<AthenzDomain> athenzDomain, Optional<AthenzService> athenzService, Notifications notifications, - List<Endpoint> endpoints) { - validateTotalDelay(steps); - this.globalServiceId = globalServiceId; - this.upgradePolicy = upgradePolicy; - this.majorVersion = majorVersion; - this.changeBlockers = changeBlockers; - this.steps = List.copyOf(completeSteps(new ArrayList<>(steps))); - this.xmlForm = xmlForm; - this.athenzDomain = athenzDomain; - this.athenzService = athenzService; - this.notifications = notifications; - this.endpoints = List.copyOf(validateEndpoints(endpoints, this.steps)); - validateZones(this.steps); - validateAthenz(); - validateEndpoints(this.steps, globalServiceId, this.endpoints); - } - - /** Validates the endpoints and makes sure default values are respected */ - private List<Endpoint> validateEndpoints(List<Endpoint> endpoints, List<Step> steps) { - Objects.requireNonNull(endpoints, "Missing endpoints parameter"); - - var productionRegions = steps.stream() - .filter(step -> step.deploysTo(Environment.prod)) - .flatMap(step -> step.zones().stream()) - .flatMap(zone -> zone.region().stream()) - .map(RegionName::value) - .collect(Collectors.toSet()); - - var rebuiltEndpointsList = new ArrayList<Endpoint>(); - - for (var endpoint : endpoints) { - if (endpoint.regions().isEmpty()) { - var rebuiltEndpoint = endpoint.withRegions(productionRegions); - rebuiltEndpointsList.add(rebuiltEndpoint); - } else { - rebuiltEndpointsList.add(endpoint); - } - } - - return List.copyOf(rebuiltEndpointsList); - } - - /** Throw an IllegalArgumentException if the total delay exceeds 24 hours */ - private void validateTotalDelay(List<Step> steps) { - long totalDelaySeconds = steps.stream().filter(step -> step instanceof Delay) - .mapToLong(delay -> ((Delay)delay).duration().getSeconds()) - .sum(); - if (totalDelaySeconds > Duration.ofHours(24).getSeconds()) - throw new IllegalArgumentException("The total delay specified is " + Duration.ofSeconds(totalDelaySeconds) + - " but max 24 hours is allowed"); - } - - /** Throw an IllegalArgumentException if any production zone is declared multiple times */ - private void validateZones(List<Step> steps) { - Set<DeclaredZone> zones = new HashSet<>(); - - for (Step step : steps) - for (DeclaredZone zone : step.zones()) - ensureUnique(zone, zones); - } - - /** Throw an IllegalArgumentException if an endpoint refers to a region that is not declared in 'prod' */ - private void validateEndpoints(List<Step> steps, Optional<String> globalServiceId, List<Endpoint> endpoints) { - if (globalServiceId.isPresent() && ! endpoints.isEmpty()) { - throw new IllegalArgumentException("Providing both 'endpoints' and 'global-service-id'. Use only 'endpoints'."); - } - - var stepZones = steps.stream() - .flatMap(s -> s.zones().stream()) - .flatMap(z -> z.region.stream()) - .collect(Collectors.toSet()); - for (var endpoint : endpoints){ - for (var endpointRegion : endpoint.regions()) { - if (! stepZones.contains(endpointRegion)) { - throw new IllegalArgumentException("Region used in endpoint that is not declared in 'prod': " + endpointRegion); - } - } + public DeploymentSpec(List<Step> steps, + Optional<Integer> majorVersion, + String xmlForm) { + if (singleInstance(steps)) { // TODO: Remove this clause after November 2019 + var singleInstance = (DeploymentInstanceSpec)steps.get(0); + this.steps = List.of(singleInstance.withSteps(completeSteps(singleInstance.steps()))); } - } - - /* - * Throw an IllegalArgumentException if Athenz configuration violates: - * domain not configured -> no zone can configure service - * domain configured -> all zones must configure service - */ - private void validateAthenz() { - // If athenz domain is not set, athenz service cannot be set on any level - if (athenzDomain.isEmpty()) { - for (DeclaredZone zone : zones()) { - if(zone.athenzService().isPresent()) { - throw new IllegalArgumentException("Athenz service configured for zone: " + zone + ", but Athenz domain is not configured"); - } - } - // if athenz domain is not set, athenz service must be set implicitly or directly on all zones. - } else if (athenzService.isEmpty()) { - for (DeclaredZone zone : zones()) { - if (zone.athenzService().isEmpty()) { - throw new IllegalArgumentException("Athenz domain is configured, but Athenz service not configured for zone: " + zone); - } - } + else { + this.steps = List.copyOf(completeSteps(steps)); } + this.majorVersion = majorVersion; + this.xmlForm = xmlForm; + validateTotalDelay(steps); } - private void ensureUnique(DeclaredZone zone, Set<DeclaredZone> zones) { - if ( ! zones.add(zone)) - throw new IllegalArgumentException(zone + " is listed twice in deployment.xml"); + // TODO: Remove after October 2019 + public DeploymentSpec(Optional<String> globalServiceId, UpgradePolicy upgradePolicy, Optional<Integer> majorVersion, + List<ChangeBlocker> changeBlockers, List<Step> steps, String xmlForm, + Optional<AthenzDomain> athenzDomain, Optional<AthenzService> athenzService, + Notifications notifications, + List<Endpoint> endpoints) { + this(List.of(new DeploymentInstanceSpec(InstanceName.from("default"), + steps, + upgradePolicy, + changeBlockers, + globalServiceId, + athenzDomain, + athenzService, + notifications, + endpoints)), + majorVersion, + xmlForm); } /** Adds missing required steps and reorders steps to a permissible order */ - private static List<Step> completeSteps(List<Step> steps) { + private static List<DeploymentSpec.Step> completeSteps(List<DeploymentSpec.Step> inputSteps) { + List<Step> steps = new ArrayList<>(inputSteps); + // Add staging if required and missing if (steps.stream().anyMatch(step -> step.deploysTo(Environment.prod)) && steps.stream().noneMatch(step -> step.deploysTo(Environment.staging))) { - steps.add(new DeclaredZone(Environment.staging)); + steps.add(new DeploymentSpec.DeclaredZone(Environment.staging)); } - + // Add test if required and missing if (steps.stream().anyMatch(step -> step.deploysTo(Environment.staging)) && steps.stream().noneMatch(step -> step.deploysTo(Environment.test))) { - steps.add(new DeclaredZone(Environment.test)); + steps.add(new DeploymentSpec.DeclaredZone(Environment.test)); } - + // Enforce order test, staging, prod - DeclaredZone testStep = remove(Environment.test, steps); + DeploymentSpec.DeclaredZone testStep = remove(Environment.test, steps); if (testStep != null) steps.add(0, testStep); - DeclaredZone stagingStep = remove(Environment.staging, steps); + DeploymentSpec.DeclaredZone stagingStep = remove(Environment.staging, steps); if (stagingStep != null) steps.add(1, stagingStep); - + return steps; } - /** + /** * Removes the first occurrence of a deployment step to the given environment and returns it. - * + * * @return the removed step, or null if it is not present */ - private static DeclaredZone remove(Environment environment, List<Step> steps) { + private static DeploymentSpec.DeclaredZone remove(Environment environment, List<DeploymentSpec.Step> steps) { for (int i = 0; i < steps.size(); i++) { - if (steps.get(i).deploysTo(environment)) - return (DeclaredZone)steps.remove(i); + if ( ! (steps.get(i) instanceof DeploymentSpec.DeclaredZone)) continue; + DeploymentSpec.DeclaredZone zoneStep = (DeploymentSpec.DeclaredZone)steps.get(i); + if (zoneStep.environment() == environment) { + steps.remove(i); + return zoneStep; + } } return null; } - /** Returns the ID of the service to expose through global routing, if present */ - public Optional<String> globalServiceId() { - return globalServiceId; + /** Throw an IllegalArgumentException if the total delay exceeds 24 hours */ + private void validateTotalDelay(List<Step> steps) { + long totalDelaySeconds = steps.stream().mapToLong(step -> (step.delay().getSeconds())).sum(); + if (totalDelaySeconds > Duration.ofHours(24).getSeconds()) + throw new IllegalArgumentException("The total delay specified is " + Duration.ofSeconds(totalDelaySeconds) + + " but max 24 hours is allowed"); } - /** Returns the upgrade policy of this, which is defaultPolicy if none is specified */ - public UpgradePolicy upgradePolicy() { return upgradePolicy; } + // TODO: Remove after October 2019 + private DeploymentInstanceSpec defaultInstance() { + if (singleInstance(steps)) return (DeploymentInstanceSpec)steps.get(0); + throw new IllegalArgumentException("This deployment spec does not support the legacy API " + + "as it has multiple instances: " + + instances().stream().map(Step::toString).collect(Collectors.joining(","))); + } + + // TODO: Remove after October 2019 + public Optional<String> globalServiceId() { return defaultInstance().globalServiceId(); } + + // TODO: Remove after October 2019 + public UpgradePolicy upgradePolicy() { return defaultInstance().upgradePolicy(); } /** Returns the major version this application is pinned to, or empty (default) to allow all major versions */ public Optional<Integer> majorVersion() { return majorVersion; } - /** Returns whether upgrade can occur at the given instant */ - public boolean canUpgradeAt(Instant instant) { - return changeBlockers.stream().filter(block -> block.blocksVersions()) - .noneMatch(block -> block.window().includes(instant)); - } + // TODO: Remove after November 2019 + public boolean canUpgradeAt(Instant instant) { return defaultInstance().canUpgradeAt(instant); } - /** Returns whether an application revision change can occur at the given instant */ - public boolean canChangeRevisionAt(Instant instant) { - return changeBlockers.stream().filter(block -> block.blocksRevisions()) - .noneMatch(block -> block.window().includes(instant)); - } + // TODO: Remove after November 2019 + public boolean canChangeRevisionAt(Instant instant) { return defaultInstance().canChangeRevisionAt(instant); } - /** Returns time windows where upgrades are disallowed */ - public List<ChangeBlocker> changeBlocker() { return changeBlockers; } + // TODO: Remove after November 2019 + public List<ChangeBlocker> changeBlocker() { return defaultInstance().changeBlocker(); } /** Returns the deployment steps of this in the order they will be performed */ - public List<Step> steps() { return steps; } + public List<Step> steps() { + if (singleInstance(steps)) return defaultInstance().steps(); // TODO: Remove line after November 2019 + return steps; + } - /** Returns all the DeclaredZone deployment steps in the order they are declared */ + // TODO: Remove after November 2019 public List<DeclaredZone> zones() { - return steps.stream() - .flatMap(step -> step.zones().stream()) - .collect(Collectors.toList()); + return defaultInstance().steps().stream() + .flatMap(step -> step.zones().stream()) + .collect(Collectors.toList()); } - /** Returns the notification configuration */ - public Notifications notifications() { return notifications; } + // TODO: Remove after November 2019 + public Optional<AthenzDomain> athenzDomain() { return defaultInstance().athenzDomain(); } - /** Returns the rotations configuration */ - public List<Endpoint> endpoints() { return endpoints; } + // TODO: Remove after November 2019 + public Optional<AthenzService> athenzService(Environment environment, RegionName region) { + return defaultInstance().athenzService(environment, region); + } + + // TODO: Remove after November 2019 + public Notifications notifications() { return defaultInstance().notifications(); } + + // TODO: Remove after November 2019 + public List<Endpoint> endpoints() { return defaultInstance().endpoints(); } /** Returns the XML form of this spec, or null if it was not created by fromXml, nor is empty */ public String xmlForm() { return xmlForm; } - /** Returns whether this deployment spec specifies the given zone, either implicitly or explicitly */ + // TODO: Remove after November 2019 public boolean includes(Environment environment, Optional<RegionName> region) { - for (Step step : steps) - if (step.deploysTo(environment, region)) return true; - return false; + return defaultInstance().deploysTo(environment, region); + } + + // TODO: Remove after November 2019 + private static boolean singleInstance(List<DeploymentSpec.Step> steps) { + return steps.size() == 1 && steps.get(0) instanceof DeploymentInstanceSpec; + } + + /** Returns the instance step containing the given instance name, or null if not present */ + public DeploymentInstanceSpec instance(String name) { + return instance(InstanceName.from(name)); + } + + /** Returns the instance step containing the given instance name, or null if not present */ + public DeploymentInstanceSpec instance(InstanceName name) { + for (DeploymentInstanceSpec instance : instances()) { + if (instance.name().equals(name)) + return instance; + } + return null; + } + + /** Returns the instance step containing the given instance name, or throws an IllegalArgumentException if not present */ + public DeploymentInstanceSpec requireInstance(String name) { + return requireInstance(InstanceName.from(name)); + } + + public DeploymentInstanceSpec requireInstance(InstanceName name) { + DeploymentInstanceSpec instance = instance(name); + if (instance == null) + throw new IllegalArgumentException("No instance '" + name + "' in deployment.xml'. Instances: " + + instances().stream().map(spec -> spec.name().toString()).collect(Collectors.joining(","))); + return instance; + } + + /** Returns the steps of this which are instances */ + public List<DeploymentInstanceSpec> instances() { + return steps.stream() + .filter(step -> step instanceof DeploymentInstanceSpec).map(DeploymentInstanceSpec.class::cast) + .collect(Collectors.toList()); } /** @@ -304,40 +280,19 @@ public class DeploymentSpec { return b.toString(); } - /** Returns the athenz domain if configured */ - public Optional<AthenzDomain> athenzDomain() { - return athenzDomain; - } - - /** Returns the athenz service for environment/region if configured */ - public Optional<AthenzService> athenzService(Environment environment, RegionName region) { - AthenzService athenzService = zones().stream() - .filter(zone -> zone.deploysTo(environment, Optional.of(region))) - .findFirst() - .flatMap(DeclaredZone::athenzService) - .orElse(this.athenzService.orElse(null)); - return Optional.ofNullable(athenzService); - } - @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - DeploymentSpec that = (DeploymentSpec) o; - return globalServiceId.equals(that.globalServiceId) && - upgradePolicy == that.upgradePolicy && - majorVersion.equals(that.majorVersion) && - changeBlockers.equals(that.changeBlockers) && - steps.equals(that.steps) && - xmlForm.equals(that.xmlForm) && - athenzDomain.equals(that.athenzDomain) && - athenzService.equals(that.athenzService) && - notifications.equals(that.notifications); + DeploymentSpec other = (DeploymentSpec) o; + return majorVersion.equals(other.majorVersion) && + steps.equals(other.steps) && + xmlForm.equals(other.xmlForm); } @Override public int hashCode() { - return Objects.hash(globalServiceId, upgradePolicy, majorVersion, changeBlockers, steps, xmlForm, athenzDomain, athenzService, notifications); + return Objects.hash(majorVersion, steps, xmlForm); } /** This may be invoked by a continuous build */ @@ -365,7 +320,7 @@ public class DeploymentSpec { /** A deployment step */ public abstract static class Step { - + /** Returns whether this step deploys to the given region */ public final boolean deploysTo(Environment environment) { return deploysTo(environment, Optional.empty()); @@ -377,6 +332,12 @@ public class DeploymentSpec { /** Returns the zones deployed to in this step */ public List<DeclaredZone> zones() { return Collections.emptyList(); } + /** The delay introduced by this step (beyond the time it takes to execute the step). Default is zero. */ + public Duration delay() { return Duration.ZERO; } + + /** Returns all the steps nested in this. This default implementatiino returns an empty list. */ + public List<Step> steps() { return List.of(); } + } /** A deployment step which is to wait for some time before progressing to the next step */ @@ -387,12 +348,21 @@ public class DeploymentSpec { public Delay(Duration duration) { this.duration = duration; } - + + // TODO: Remove after October 2019 public Duration duration() { return duration; } @Override + public Duration delay() { return duration; } + + @Override public boolean deploysTo(Environment environment, Optional<RegionName> region) { return false; } + @Override + public String toString() { + return "delay " + duration; + } + } /** A deployment step which is to run deployment in a particular zone */ @@ -473,21 +443,31 @@ public class DeploymentSpec { } - /** A deployment step which is to run deployment to multiple zones in parallel */ + /** A deployment step which is to run multiple steps (zones or instances) in parallel */ public static class ParallelZones extends Step { - private final List<DeclaredZone> zones; + private final List<Step> steps; + + public ParallelZones(List<Step> steps) { + this.steps = List.copyOf(steps); + } - public ParallelZones(List<DeclaredZone> zones) { - this.zones = List.copyOf(zones); + /** Returns the steps inside this which are zones */ + @Override + public List<DeclaredZone> zones() { + return this.steps.stream() + .filter(step -> step instanceof DeclaredZone) + .map(DeclaredZone.class::cast) + .collect(Collectors.toList()); } + /** Returns all the steps nested in this */ @Override - public List<DeclaredZone> zones() { return this.zones; } + public List<Step> steps() { return steps; } @Override public boolean deploysTo(Environment environment, Optional<RegionName> region) { - return zones.stream().anyMatch(zone -> zone.deploysTo(environment, region)); + return steps().stream().anyMatch(zone -> zone.deploysTo(environment, region)); } @Override @@ -495,13 +475,19 @@ public class DeploymentSpec { if (this == o) return true; if (!(o instanceof ParallelZones)) return false; ParallelZones that = (ParallelZones) o; - return Objects.equals(zones, that.zones); + return Objects.equals(steps, that.steps); } @Override public int hashCode() { - return Objects.hash(zones); + return Objects.hash(steps); } + + @Override + public String toString() { + return steps.size() + " parallel steps"; + } + } /** Controls when this application will be upgraded to new Vespa versions */ @@ -530,6 +516,11 @@ public class DeploymentSpec { public boolean blocksRevisions() { return revision; } public boolean blocksVersions() { return version; } public TimeWindow window() { return window; } + + @Override + public String toString() { + return "change blocker revision=" + revision + " version=" + version + " window=" + window; + } } diff --git a/config-model-api/src/main/java/com/yahoo/config/application/api/xml/DeploymentSpecXmlReader.java b/config-model-api/src/main/java/com/yahoo/config/application/api/xml/DeploymentSpecXmlReader.java index 72a806bb7be..cb645813290 100644 --- a/config-model-api/src/main/java/com/yahoo/config/application/api/xml/DeploymentSpecXmlReader.java +++ b/config-model-api/src/main/java/com/yahoo/config/application/api/xml/DeploymentSpecXmlReader.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.api.xml; +import com.yahoo.config.application.api.DeploymentInstanceSpec; import com.yahoo.config.application.api.DeploymentSpec; import com.yahoo.config.application.api.DeploymentSpec.DeclaredZone; import com.yahoo.config.application.api.DeploymentSpec.Delay; @@ -14,6 +15,7 @@ import com.yahoo.config.application.api.TimeWindow; import com.yahoo.config.provision.AthenzDomain; import com.yahoo.config.provision.AthenzService; import com.yahoo.config.provision.Environment; +import com.yahoo.config.provision.InstanceName; import com.yahoo.config.provision.RegionName; import com.yahoo.io.IOUtils; import com.yahoo.text.XML; @@ -38,27 +40,36 @@ import java.util.stream.Collectors; */ public class DeploymentSpecXmlReader { + private static final String instanceTag = "instance"; private static final String majorVersionTag = "major-version"; private static final String testTag = "test"; private static final String stagingTag = "staging"; + private static final String upgradeTag = "upgrade"; private static final String blockChangeTag = "block-change"; private static final String prodTag = "prod"; + private static final String regionTag = "region"; + private static final String delayTag = "delay"; + private static final String parallelTag = "parallel"; private static final String endpointsTag = "endpoints"; private static final String endpointTag = "endpoint"; + private static final String notificationsTag = "notifications"; + + private static final String idAttribute = "id"; + private static final String athenzServiceAttribute = "athenz-service"; + private static final String athenzDomainAttribute = "athenz-domain"; + private static final String testerFlavorAttribute = "tester-flavor"; private final boolean validate; - /** - * Creates a validating reader - */ + /** Creates a validating reader */ public DeploymentSpecXmlReader() { this(true); } /** - * Creates a reader + * Creates a deployment spec reader * - * @param validate true to validate the input, false to accept any input which can be unabiguously parsed + * @param validate true to validate the input, false to accept any input which can be unambiguously parsed */ public DeploymentSpecXmlReader(boolean validate) { this.validate = validate; @@ -73,67 +84,135 @@ public class DeploymentSpecXmlReader { } } - /** - * Reads a deployment spec from XML - */ + /** Reads a deployment spec from XML */ public DeploymentSpec read(String xmlForm) { - List<Step> steps = new ArrayList<>(); - Optional<String> globalServiceId = Optional.empty(); Element root = XML.getDocument(xmlForm).getDocumentElement(); - if (validate) - validateTagOrder(root); - for (Element environmentTag : XML.getChildren(root)) { - if (!isEnvironmentName(environmentTag.getTagName())) continue; - - Environment environment = Environment.from(environmentTag.getTagName()); - Optional<AthenzService> athenzService = stringAttribute("athenz-service", environmentTag).map(AthenzService::from); - Optional<String> testerFlavor = stringAttribute("tester-flavor", environmentTag); - - if (environment == Environment.prod) { - for (Element stepTag : XML.getChildren(environmentTag)) { - if (stepTag.getTagName().equals("delay")) { - steps.add(new Delay(Duration.ofSeconds(longAttribute("hours", stepTag) * 60 * 60 + - longAttribute("minutes", stepTag) * 60 + - longAttribute("seconds", stepTag)))); - } - else if (stepTag.getTagName().equals("parallel")) { - List<DeclaredZone> zones = new ArrayList<>(); - for (Element regionTag : XML.getChildren(stepTag)) { - zones.add(readDeclaredZone(environment, athenzService, testerFlavor, regionTag)); - } - steps.add(new ParallelZones(zones)); - } - else { // a region: deploy step - steps.add(readDeclaredZone(environment, athenzService, testerFlavor, stepTag)); - } - } - } - else { - steps.add(new DeclaredZone(environment, Optional.empty(), false, athenzService, testerFlavor)); + + List<Step> steps = new ArrayList<>(); + if ( ! containsTag(instanceTag, root)) { // deployment spec skipping explicit instance -> "default" instance + steps.addAll(readInstanceContent("default", root, new MutableOptional<>(), root)); + } + else { + if (XML.getChildren(root).stream().anyMatch(child -> child.getTagName().equals(prodTag))) + throw new IllegalArgumentException("A deployment spec cannot have both a <prod> tag and an " + + "<instance> tag under the root: " + + "Wrap the prod tags inside the appropriate instance"); + + for (Element topLevelTag : XML.getChildren(root)) { + if (topLevelTag.getTagName().equals(instanceTag)) + steps.addAll(readInstanceContent(topLevelTag.getAttribute(idAttribute), topLevelTag, new MutableOptional<>(), root)); + else + steps.addAll(readNonInstanceSteps(topLevelTag, new MutableOptional<>(), topLevelTag)); // (No global service id here) } + } + + return new DeploymentSpec(steps, + optionalIntegerAttribute(majorVersionTag, root), + xmlForm); + } + + /** + * Reads the content of an (implicit or explicit) instance tag producing an instances step + * + * @param instanceNameString a comma-separated list of the names of the instances this is for + * @param instanceTag the element having the content of this instance + * @param parentTag the parent of instanceTag (or the same, if this instances is implicitly defined which means instanceTag is the root) + * @return the instances specified, one for each instance name element + */ + private List<DeploymentInstanceSpec> readInstanceContent(String instanceNameString, + Element instanceTag, + MutableOptional<String> globalServiceId, + Element parentTag) { + if (validate) + validateTagOrder(instanceTag); + + // Values where the parent may provide a default + DeploymentSpec.UpgradePolicy upgradePolicy = readUpgradePolicy(instanceTag, parentTag); + List<DeploymentSpec.ChangeBlocker> changeBlockers = readChangeBlockers(instanceTag, parentTag); + Optional<AthenzDomain> athenzDomain = stringAttribute(athenzDomainAttribute, instanceTag) + .or(() -> stringAttribute(athenzDomainAttribute, parentTag)) + .map(AthenzDomain::from); + Optional<AthenzService> athenzService = stringAttribute(athenzServiceAttribute, instanceTag) + .or(() -> stringAttribute(athenzServiceAttribute, parentTag)) + .map(AthenzService::from); + Notifications notifications = readNotifications(instanceTag, parentTag); + + // Values where there is no default + List<Step> steps = new ArrayList<>(); + for (Element instanceChild : XML.getChildren(instanceTag)) + steps.addAll(readNonInstanceSteps(instanceChild, globalServiceId, instanceChild)); + List<Endpoint> endpoints = readEndpoints(instanceTag); + + // Build and return instances with these values + return Arrays.stream(instanceNameString.split(",")) + .map(name -> name.trim()) + .map(name -> new DeploymentInstanceSpec(InstanceName.from(name), + steps, + upgradePolicy, + changeBlockers, + globalServiceId.asOptional(), + athenzDomain, + athenzService, + notifications, + endpoints)) + .collect(Collectors.toList()); + } + + private List<Step> readSteps(Element stepTag, MutableOptional<String> globalServiceId, Element parentTag) { + if (stepTag.getTagName().equals(instanceTag)) + return new ArrayList<>(readInstanceContent(stepTag.getAttribute(idAttribute), stepTag, globalServiceId, parentTag)); + else + return readNonInstanceSteps(stepTag, globalServiceId, parentTag); - if (environment == Environment.prod) - globalServiceId = readGlobalServiceId(environmentTag); - else if (readGlobalServiceId(environmentTag).isPresent()) - throw new IllegalArgumentException("Attribute 'global-service-id' is only valid on 'prod' tag."); + } + // Consume the given tag as 0-N steps. 0 if it is not a step, >1 if it contains multiple nested steps that should be flattened + private List<Step> readNonInstanceSteps(Element stepTag, MutableOptional<String> globalServiceId, Element parentTag) { + Optional<AthenzService> athenzService = stringAttribute(athenzServiceAttribute, stepTag) + .or(() -> stringAttribute(athenzServiceAttribute, parentTag)) + .map(AthenzService::from); + Optional<String> testerFlavor = stringAttribute(testerFlavorAttribute, stepTag) + .or(() -> stringAttribute(testerFlavorAttribute, parentTag)); + + if (prodTag.equals(stepTag.getTagName())) + globalServiceId.set(readGlobalServiceId(stepTag)); + else if (readGlobalServiceId(stepTag).isPresent()) + throw new IllegalArgumentException("Attribute 'global-service-id' is only valid on 'prod' tag."); + + switch (stepTag.getTagName()) { + case testTag: case stagingTag: + return List.of(new DeclaredZone(Environment.from(stepTag.getTagName()), Optional.empty(), false, athenzService, testerFlavor)); + case prodTag: // regions, delay and parallel may be nested within, but we can flatten them + return XML.getChildren(stepTag).stream() + .flatMap(child -> readNonInstanceSteps(child, globalServiceId, stepTag).stream()) + .collect(Collectors.toList()); + case delayTag: + return List.of(new Delay(Duration.ofSeconds(longAttribute("hours", stepTag) * 60 * 60 + + longAttribute("minutes", stepTag) * 60 + + longAttribute("seconds", stepTag)))); + case parallelTag: // regions and instances may be nested within + return List.of(new ParallelZones(XML.getChildren(stepTag).stream() + .flatMap(child -> readSteps(child, globalServiceId, stepTag).stream()) + .collect(Collectors.toList()))); + case regionTag: + return List.of(readDeclaredZone(Environment.prod, athenzService, testerFlavor, stepTag)); + default: + return List.of(); } - Optional<AthenzDomain> athenzDomain = stringAttribute("athenz-domain", root).map(AthenzDomain::from); - Optional<AthenzService> athenzService = stringAttribute("athenz-service", root).map(AthenzService::from); - return new DeploymentSpec(globalServiceId, - readUpgradePolicy(root), - optionalIntegerAttribute(majorVersionTag, root), - readChangeBlockers(root), - steps, - xmlForm, - athenzDomain, - athenzService, - readNotifications(root), - readEndpoints(root)); } - private Notifications readNotifications(Element root) { - Element notificationsElement = XML.getChild(root, "notifications"); + private boolean containsTag(String childTagName, Element parent) { + for (Element child : XML.getChildren(parent)) { + if (child.getTagName().equals(childTagName) || containsTag(childTagName, child)) + return true; + } + return false; + } + + private Notifications readNotifications(Element parent, Element fallbackParent) { + Element notificationsElement = XML.getChild(parent, notificationsTag); + if (notificationsElement == null) + notificationsElement = XML.getChild(fallbackParent, notificationsTag); if (notificationsElement == null) return Notifications.none(); @@ -158,16 +237,17 @@ public class DeploymentSpecXmlReader { return Notifications.of(emailAddresses, emailRoles); } - private List<Endpoint> readEndpoints(Element root) { - final var endpointsElement = XML.getChild(root, endpointsTag); - if (endpointsElement == null) { return Collections.emptyList(); } + private List<Endpoint> readEndpoints(Element parent) { + var endpointsElement = XML.getChild(parent, endpointsTag); + if (endpointsElement == null) + return Collections.emptyList(); - final var endpoints = new LinkedHashMap<String, Endpoint>(); + var endpoints = new LinkedHashMap<String, Endpoint>(); for (var endpointElement : XML.getChildren(endpointsElement, endpointTag)) { - final Optional<String> rotationId = stringAttribute("id", endpointElement); - final Optional<String> containerId = stringAttribute("container-id", endpointElement); - final var regions = new HashSet<String>(); + Optional<String> rotationId = stringAttribute("id", endpointElement); + Optional<String> containerId = stringAttribute("container-id", endpointElement); + var regions = new HashSet<String>(); if (containerId.isEmpty()) { throw new IllegalArgumentException("Missing 'container-id' from 'endpoint' tag."); @@ -255,10 +335,6 @@ public class DeploymentSpecXmlReader { return Optional.ofNullable(value).filter(s -> !s.equals("")); } - private boolean isEnvironmentName(String tagName) { - return tagName.equals(testTag) || tagName.equals(stagingTag) || tagName.equals(prodTag); - } - private DeclaredZone readDeclaredZone(Environment environment, Optional<AthenzService> athenzService, Optional<String> testerFlavor, Element regionTag) { return new DeclaredZone(environment, Optional.of(RegionName.from(XML.getValue(regionTag).trim())), @@ -267,44 +343,44 @@ public class DeploymentSpecXmlReader { private Optional<String> readGlobalServiceId(Element environmentTag) { String globalServiceId = environmentTag.getAttribute("global-service-id"); - if (globalServiceId == null || globalServiceId.isEmpty()) { - return Optional.empty(); - } - else { - return Optional.of(globalServiceId); - } + if (globalServiceId == null || globalServiceId.isEmpty()) return Optional.empty(); + return Optional.of(globalServiceId); } - private List<DeploymentSpec.ChangeBlocker> readChangeBlockers(Element root) { + private List<DeploymentSpec.ChangeBlocker> readChangeBlockers(Element parent, Element globalBlockersParent) { List<DeploymentSpec.ChangeBlocker> changeBlockers = new ArrayList<>(); - for (Element tag : XML.getChildren(root)) { - if (!blockChangeTag.equals(tag.getTagName())) continue; - - boolean blockVersions = trueOrMissing(tag.getAttribute("version")); - boolean blockRevisions = trueOrMissing(tag.getAttribute("revision")); - - String daySpec = tag.getAttribute("days"); - String hourSpec = tag.getAttribute("hours"); - String zoneSpec = tag.getAttribute("time-zone"); - if (zoneSpec.isEmpty()) { // Default to UTC time zone - zoneSpec = "UTC"; - } - changeBlockers.add(new DeploymentSpec.ChangeBlocker(blockRevisions, blockVersions, - TimeWindow.from(daySpec, hourSpec, zoneSpec))); + if (globalBlockersParent != parent) { + for (Element tag : XML.getChildren(globalBlockersParent, blockChangeTag)) + changeBlockers.add(readChangeBlocker(tag)); } + for (Element tag : XML.getChildren(parent, blockChangeTag)) + changeBlockers.add(readChangeBlocker(tag)); return Collections.unmodifiableList(changeBlockers); } - /** - * Returns true if the given value is "true", or if it is missing - */ + private DeploymentSpec.ChangeBlocker readChangeBlocker(Element tag) { + boolean blockVersions = trueOrMissing(tag.getAttribute("version")); + boolean blockRevisions = trueOrMissing(tag.getAttribute("revision")); + + String daySpec = tag.getAttribute("days"); + String hourSpec = tag.getAttribute("hours"); + String zoneSpec = tag.getAttribute("time-zone"); + if (zoneSpec.isEmpty()) zoneSpec = "UTC"; // default + return new DeploymentSpec.ChangeBlocker(blockRevisions, blockVersions, + TimeWindow.from(daySpec, hourSpec, zoneSpec)); + } + + /** Returns true if the given value is "true", or if it is missing */ private boolean trueOrMissing(String value) { return value == null || value.isEmpty() || value.equals("true"); } - private DeploymentSpec.UpgradePolicy readUpgradePolicy(Element root) { - Element upgradeElement = XML.getChild(root, "upgrade"); - if (upgradeElement == null) return DeploymentSpec.UpgradePolicy.defaultPolicy; + private DeploymentSpec.UpgradePolicy readUpgradePolicy(Element parent, Element fallbackParent) { + Element upgradeElement = XML.getChild(parent, upgradeTag); + if (upgradeElement == null) + upgradeElement = XML.getChild(fallbackParent, upgradeTag); + if (upgradeElement == null) + return DeploymentSpec.UpgradePolicy.defaultPolicy; String policy = upgradeElement.getAttribute("policy"); switch (policy) { @@ -324,4 +400,14 @@ public class DeploymentSpecXmlReader { "to control whether the region should receive production traffic"); } + private static class MutableOptional<T> { + + private Optional<T> value = Optional.empty(); + + public void set(Optional<T> value) { this.value = value; } + + public Optional<T> asOptional() { return value; } + + } + } diff --git a/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecDeprecatedAPITest.java b/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecDeprecatedAPITest.java new file mode 100644 index 00000000000..dabdd0c4a69 --- /dev/null +++ b/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecDeprecatedAPITest.java @@ -0,0 +1,572 @@ +// 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.api; + +import com.google.common.collect.ImmutableSet; +import com.yahoo.config.provision.Environment; +import com.yahoo.config.provision.RegionName; +import org.junit.Test; + +import java.io.StringReader; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.yahoo.config.application.api.Notifications.Role.author; +import static com.yahoo.config.application.api.Notifications.When.failing; +import static com.yahoo.config.application.api.Notifications.When.failingCommit; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * @author bratseth + */ +// TODO: Remove after October 2019 +public class DeploymentSpecDeprecatedAPITest { + + @Test + public void testSpec() { + String specXml = "<deployment version='1.0'>" + + " <test/>" + + "</deployment>"; + + StringReader r = new StringReader(specXml); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(specXml, spec.xmlForm()); + assertEquals(1, spec.steps().size()); + assertFalse(spec.majorVersion().isPresent()); + assertTrue(spec.steps().get(0).deploysTo(Environment.test)); + assertTrue(spec.includes(Environment.test, Optional.empty())); + assertFalse(spec.includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertFalse(spec.includes(Environment.staging, Optional.empty())); + assertFalse(spec.includes(Environment.prod, Optional.empty())); + assertFalse(spec.globalServiceId().isPresent()); + } + + @Test + public void testSpecPinningMajorVersion() { + String specXml = "<deployment version='1.0' major-version='6'>" + + " <test/>" + + "</deployment>"; + + StringReader r = new StringReader(specXml); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(specXml, spec.xmlForm()); + assertEquals(1, spec.steps().size()); + assertTrue(spec.majorVersion().isPresent()); + assertEquals(6, (int)spec.majorVersion().get()); + } + + @Test + public void stagingSpec() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <staging/>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(2, spec.steps().size()); + assertTrue(spec.steps().get(0).deploysTo(Environment.test)); + assertTrue(spec.steps().get(1).deploysTo(Environment.staging)); + assertTrue(spec.includes(Environment.test, Optional.empty())); + assertFalse(spec.includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertTrue(spec.includes(Environment.staging, Optional.empty())); + assertFalse(spec.includes(Environment.prod, Optional.empty())); + assertFalse(spec.globalServiceId().isPresent()); + } + + @Test + public void minimalProductionSpec() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <prod>" + + " <region active='false'>us-east1</region>" + + " <region active='true'>us-west1</region>" + + " </prod>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(4, spec.steps().size()); + + assertTrue(spec.steps().get(0).deploysTo(Environment.test)); + + assertTrue(spec.steps().get(1).deploysTo(Environment.staging)); + + assertTrue(spec.steps().get(2).deploysTo(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertFalse(((DeploymentSpec.DeclaredZone)spec.steps().get(2)).active()); + + assertTrue(spec.steps().get(3).deploysTo(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertTrue(((DeploymentSpec.DeclaredZone)spec.steps().get(3)).active()); + + assertTrue(spec.includes(Environment.test, Optional.empty())); + assertFalse(spec.includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertTrue(spec.includes(Environment.staging, Optional.empty())); + assertTrue(spec.includes(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertTrue(spec.includes(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertFalse(spec.includes(Environment.prod, Optional.of(RegionName.from("no-such-region")))); + assertFalse(spec.globalServiceId().isPresent()); + + assertEquals(DeploymentSpec.UpgradePolicy.defaultPolicy, spec.upgradePolicy()); + } + + @Test + public void maximalProductionSpec() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <test/>" + + " <staging/>" + + " <prod>" + + " <region active='false'>us-east1</region>" + + " <delay hours='3' minutes='30'/>" + + " <region active='true'>us-west1</region>" + + " </prod>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(5, spec.steps().size()); + assertEquals(4, spec.zones().size()); + + assertTrue(spec.steps().get(0).deploysTo(Environment.test)); + + assertTrue(spec.steps().get(1).deploysTo(Environment.staging)); + + assertTrue(spec.steps().get(2).deploysTo(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertFalse(((DeploymentSpec.DeclaredZone)spec.steps().get(2)).active()); + + assertTrue(spec.steps().get(3) instanceof DeploymentSpec.Delay); + assertEquals(3 * 60 * 60 + 30 * 60, ((DeploymentSpec.Delay)spec.steps().get(3)).duration().getSeconds()); + + assertTrue(spec.steps().get(4).deploysTo(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertTrue(((DeploymentSpec.DeclaredZone)spec.steps().get(4)).active()); + + assertTrue(spec.includes(Environment.test, Optional.empty())); + assertFalse(spec.includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertTrue(spec.includes(Environment.staging, Optional.empty())); + assertTrue(spec.includes(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertTrue(spec.includes(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertFalse(spec.includes(Environment.prod, Optional.of(RegionName.from("no-such-region")))); + assertFalse(spec.globalServiceId().isPresent()); + } + + @Test + public void productionSpecWithGlobalServiceId() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <prod global-service-id='query'>" + + " <region active='true'>us-east-1</region>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(spec.globalServiceId(), Optional.of("query")); + } + + @Test(expected=IllegalArgumentException.class) + public void globalServiceIdInTest() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <test global-service-id='query' />" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + } + + @Test(expected=IllegalArgumentException.class) + public void globalServiceIdInStaging() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <staging global-service-id='query' />" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + } + + @Test + public void productionSpecWithGlobalServiceIdBeforeStaging() { + StringReader r = new StringReader( + "<deployment>" + + " <test/>" + + " <prod global-service-id='qrs'>" + + " <region active='true'>us-west-1</region>" + + " <region active='true'>us-central-1</region>" + + " <region active='true'>us-east-3</region>" + + " </prod>" + + " <staging/>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals("qrs", spec.globalServiceId().get()); + } + + @Test + public void productionSpecWithUpgradePolicy() { + StringReader r = new StringReader( + "<deployment>" + + " <upgrade policy='canary'/>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " <region active='true'>us-central-1</region>" + + " <region active='true'>us-east-3</region>" + + " </prod>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals("canary", spec.upgradePolicy().toString()); + } + + @Test + public void maxDelayExceeded() { + try { + StringReader r = new StringReader( + "<deployment>" + + " <upgrade policy='canary'/>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " <delay hours='23'/>" + + " <region active='true'>us-central-1</region>" + + " <delay minutes='59' seconds='61'/>" + + " <region active='true'>us-east-3</region>" + + " </prod>" + + "</deployment>" + ); + DeploymentSpec.fromXml(r); + fail("Expected exception due to exceeding the max total delay"); + } + catch (IllegalArgumentException e) { + // success + assertEquals("The total delay specified is PT24H1S but max 24 hours is allowed", e.getMessage()); + } + } + + @Test + public void testEmpty() { + assertFalse(DeploymentSpec.empty.globalServiceId().isPresent()); + assertEquals(DeploymentSpec.UpgradePolicy.defaultPolicy, DeploymentSpec.empty.upgradePolicy()); + assertTrue(DeploymentSpec.empty.steps().isEmpty()); + assertEquals("<deployment version='1.0'/>", DeploymentSpec.empty.xmlForm()); + } + + @Test + public void productionSpecWithParallelDeployments() { + StringReader r = new StringReader( + "<deployment>\n" + + " <prod> \n" + + " <region active='true'>us-west-1</region>\n" + + " <parallel>\n" + + " <region active='true'>us-central-1</region>\n" + + " <region active='true'>us-east-3</region>\n" + + " </parallel>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + DeploymentSpec.ParallelZones parallelZones = ((DeploymentSpec.ParallelZones) spec.steps().get(3)); + assertEquals(2, parallelZones.zones().size()); + assertEquals(RegionName.from("us-central-1"), parallelZones.zones().get(0).region().get()); + assertEquals(RegionName.from("us-east-3"), parallelZones.zones().get(1).region().get()); + } + + @Test + public void productionSpecWithDuplicateRegions() { + StringReader r = new StringReader( + "<deployment>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " <parallel>\n" + + " <region active='true'>us-west-1</region>\n" + + " <region active='true'>us-central-1</region>\n" + + " <region active='true'>us-east-3</region>\n" + + " </parallel>\n" + + " </prod>\n" + + "</deployment>" + ); + try { + DeploymentSpec.fromXml(r); + fail("Expected exception"); + } catch (IllegalArgumentException e) { + assertEquals("prod.us-west-1 is listed twice in deployment.xml", e.getMessage()); + } + } + + @Test(expected = IllegalArgumentException.class) + public void deploymentSpecWithIllegallyOrderedDeploymentSpec1() { + StringReader r = new StringReader( + "<deployment>\n" + + " <block-change days='sat' hours='10' time-zone='CET'/>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + " <block-change days='mon,tue' hours='15-16'/>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + } + + @Test(expected = IllegalArgumentException.class) + public void deploymentSpecWithIllegallyOrderedDeploymentSpec2() { + StringReader r = new StringReader( + "<deployment>\n" + + " <block-change days='sat' hours='10' time-zone='CET'/>\n" + + " <test/>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + } + + @Test + public void deploymentSpecWithChangeBlocker() { + StringReader r = new StringReader( + "<deployment>\n" + + " <block-change revision='false' days='mon,tue' hours='15-16'/>\n" + + " <block-change days='sat' hours='10' time-zone='CET'/>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(2, spec.changeBlocker().size()); + assertTrue(spec.changeBlocker().get(0).blocksVersions()); + assertFalse(spec.changeBlocker().get(0).blocksRevisions()); + assertEquals(ZoneId.of("UTC"), spec.changeBlocker().get(0).window().zone()); + + assertTrue(spec.changeBlocker().get(1).blocksVersions()); + assertTrue(spec.changeBlocker().get(1).blocksRevisions()); + assertEquals(ZoneId.of("CET"), spec.changeBlocker().get(1).window().zone()); + + assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-18T14:15:30.00Z"))); + assertFalse(spec.canUpgradeAt(Instant.parse("2017-09-18T15:15:30.00Z"))); + assertFalse(spec.canUpgradeAt(Instant.parse("2017-09-18T16:15:30.00Z"))); + assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-18T17:15:30.00Z"))); + + assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-23T09:15:30.00Z"))); + assertFalse(spec.canUpgradeAt(Instant.parse("2017-09-23T08:15:30.00Z"))); // 10 in CET + assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-23T10:15:30.00Z"))); + } + + @Test + public void athenz_config_is_read_from_deployment() { + StringReader r = new StringReader( + "<deployment athenz-domain='domain' athenz-service='service'>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(spec.athenzDomain().get().value(), "domain"); + assertEquals(spec.athenzService(Environment.prod, RegionName.from("us-west-1")).get().value(), "service"); + } + + @Test + public void athenz_service_is_overridden_from_environment() { + StringReader r = new StringReader( + "<deployment athenz-domain='domain' athenz-service='service'>\n" + + " <test/>\n" + + " <prod athenz-service='prod-service'>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(spec.athenzDomain().get().value(), "domain"); + assertEquals(spec.athenzService(Environment.prod, RegionName.from("us-west-1")).get().value(), "prod-service"); + } + + @Test(expected = IllegalArgumentException.class) + public void it_fails_when_athenz_service_is_not_defined() { + StringReader r = new StringReader( + "<deployment athenz-domain='domain'>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + } + + @Test(expected = IllegalArgumentException.class) + public void it_fails_when_athenz_service_is_configured_but_not_athenz_domain() { + StringReader r = new StringReader( + "<deployment>\n" + + " <prod athenz-service='service'>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + } + + @Test + public void noNotifications() { + assertEquals(Notifications.none(), + DeploymentSpec.fromXml("<deployment />").notifications()); + } + + @Test + public void emptyNotifications() { + DeploymentSpec spec = DeploymentSpec.fromXml("<deployment>\n" + + " <notifications />" + + "</deployment>"); + assertEquals(Notifications.none(), + spec.notifications()); + } + + @Test + public void someNotifications() { + DeploymentSpec spec = DeploymentSpec.fromXml("<deployment>\n" + + " <notifications when=\"failing\">\n" + + " <email role=\"author\"/>\n" + + " <email address=\"john@dev\" when=\"failing-commit\"/>\n" + + " <email address=\"jane@dev\"/>\n" + + " </notifications>\n" + + "</deployment>"); + assertEquals(ImmutableSet.of(author), spec.notifications().emailRolesFor(failing)); + assertEquals(ImmutableSet.of(author), spec.notifications().emailRolesFor(failingCommit)); + assertEquals(ImmutableSet.of("john@dev", "jane@dev"), spec.notifications().emailAddressesFor(failingCommit)); + assertEquals(ImmutableSet.of("jane@dev"), spec.notifications().emailAddressesFor(failing)); + } + + @Test + public void customTesterFlavor() { + DeploymentSpec spec = DeploymentSpec.fromXml("<deployment>\n" + + " <test tester-flavor=\"d-1-4-20\" />\n" + + " <prod tester-flavor=\"d-2-8-50\">\n" + + " <region active=\"false\">us-north-7</region>\n" + + " </prod>\n" + + "</deployment>"); + assertEquals(Optional.of("d-1-4-20"), spec.steps().get(0).zones().get(0).testerFlavor()); + assertEquals(Optional.empty(), spec.steps().get(1).zones().get(0).testerFlavor()); + assertEquals(Optional.of("d-2-8-50"), spec.steps().get(2).zones().get(0).testerFlavor()); + } + + @Test + public void noEndpoints() { + assertEquals(Collections.emptyList(), DeploymentSpec.fromXml("<deployment />").endpoints()); + } + + @Test + public void emptyEndpoints() { + final var spec = DeploymentSpec.fromXml("<deployment><endpoints/></deployment>"); + assertEquals(Collections.emptyList(), spec.endpoints()); + } + + @Test + public void someEndpoints() { + final var spec = DeploymentSpec.fromXml("" + + "<deployment>" + + " <prod>" + + " <region active=\"true\">us-east</region>" + + " </prod>" + + " <endpoints>" + + " <endpoint id=\"foo\" container-id=\"bar\">" + + " <region>us-east</region>" + + " </endpoint>" + + " <endpoint id=\"nalle\" container-id=\"frosk\" />" + + " <endpoint container-id=\"quux\" />" + + " </endpoints>" + + "</deployment>"); + + assertEquals( + List.of("foo", "nalle", "default"), + spec.endpoints().stream().map(Endpoint::endpointId).collect(Collectors.toList()) + ); + + assertEquals( + List.of("bar", "frosk", "quux"), + spec.endpoints().stream().map(Endpoint::containerId).collect(Collectors.toList()) + ); + + assertEquals(Set.of(RegionName.from("us-east")), spec.endpoints().get(0).regions()); + } + @Test + public void invalidEndpoints() { + assertInvalid("<endpoint id='FOO' container-id='qrs'/>"); // Uppercase + assertInvalid("<endpoint id='123' container-id='qrs'/>"); // Starting with non-character + assertInvalid("<endpoint id='foo!' container-id='qrs'/>"); // Non-alphanumeric + assertInvalid("<endpoint id='foo.bar' container-id='qrs'/>"); + assertInvalid("<endpoint id='foo--bar' container-id='qrs'/>"); // Multiple consecutive dashes + assertInvalid("<endpoint id='foo-' container-id='qrs'/>"); // Trailing dash + assertInvalid("<endpoint id='foooooooooooo' container-id='qrs'/>"); // Too long + assertInvalid("<endpoint id='foo' container-id='qrs'/><endpoint id='foo' container-id='qrs'/>"); // Duplicate + } + + @Test + public void validEndpoints() { + assertEquals(List.of("default"), endpointIds("<endpoint container-id='qrs'/>")); + assertEquals(List.of("default"), endpointIds("<endpoint id='' container-id='qrs'/>")); + assertEquals(List.of("f"), endpointIds("<endpoint id='f' container-id='qrs'/>")); + assertEquals(List.of("foo"), endpointIds("<endpoint id='foo' container-id='qrs'/>")); + assertEquals(List.of("foo-bar"), endpointIds("<endpoint id='foo-bar' container-id='qrs'/>")); + assertEquals(List.of("foo", "bar"), endpointIds("<endpoint id='foo' container-id='qrs'/><endpoint id='bar' container-id='qrs'/>")); + assertEquals(List.of("fooooooooooo"), endpointIds("<endpoint id='fooooooooooo' container-id='qrs'/>")); + } + + @Test + public void endpointDefaultRegions() { + var spec = DeploymentSpec.fromXml("" + + "<deployment>" + + " <prod>" + + " <region active=\"true\">us-east</region>" + + " <region active=\"true\">us-west</region>" + + " </prod>" + + " <endpoints>" + + " <endpoint id=\"foo\" container-id=\"bar\">" + + " <region>us-east</region>" + + " </endpoint>" + + " <endpoint id=\"nalle\" container-id=\"frosk\" />" + + " <endpoint container-id=\"quux\" />" + + " </endpoints>" + + "</deployment>"); + + assertEquals(Set.of("us-east"), endpointRegions("foo", spec)); + assertEquals(Set.of("us-east", "us-west"), endpointRegions("nalle", spec)); + assertEquals(Set.of("us-east", "us-west"), endpointRegions("default", spec)); + } + + private static void assertInvalid(String endpointTag) { + try { + endpointIds(endpointTag); + fail("Expected exception for input '" + endpointTag + "'"); + } catch (IllegalArgumentException ignored) {} + } + + private static Set<String> endpointRegions(String endpointId, DeploymentSpec spec) { + return spec.endpoints().stream() + .filter(endpoint -> endpoint.endpointId().equals(endpointId)) + .flatMap(endpoint -> endpoint.regions().stream()) + .map(RegionName::value) + .collect(Collectors.toSet()); + } + + private static List<String> endpointIds(String endpointTag) { + var xml = "<deployment>" + + " <prod>" + + " <region active=\"true\">us-east</region>" + + " </prod>" + + " <endpoints>" + + endpointTag + + " </endpoints>" + + "</deployment>"; + + return DeploymentSpec.fromXml(xml).endpoints().stream() + .map(Endpoint::endpointId) + .collect(Collectors.toList()); + } + +} diff --git a/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecTest.java b/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecTest.java index 47eaf7a515a..b75801de7ea 100644 --- a/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecTest.java +++ b/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecTest.java @@ -14,7 +14,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; -import java.util.stream.Stream; import static com.yahoo.config.application.api.Notifications.Role.author; import static com.yahoo.config.application.api.Notifications.When.failing; @@ -32,32 +31,36 @@ public class DeploymentSpecTest { @Test public void testSpec() { String specXml = "<deployment version='1.0'>" + - " <test/>" + + " <instance id='default'>" + + " <test/>" + + " </instance>" + "</deployment>"; StringReader r = new StringReader(specXml); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals(specXml, spec.xmlForm()); - assertEquals(1, spec.steps().size()); + assertEquals(1, spec.instance("default").steps().size()); assertFalse(spec.majorVersion().isPresent()); - assertTrue(spec.steps().get(0).deploysTo(Environment.test)); - assertTrue(spec.includes(Environment.test, Optional.empty())); - assertFalse(spec.includes(Environment.test, Optional.of(RegionName.from("region1")))); - assertFalse(spec.includes(Environment.staging, Optional.empty())); - assertFalse(spec.includes(Environment.prod, Optional.empty())); - assertFalse(spec.globalServiceId().isPresent()); + assertTrue(spec.instance("default").steps().get(0).deploysTo(Environment.test)); + assertTrue(spec.instance("default").includes(Environment.test, Optional.empty())); + assertFalse(spec.instance("default").includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertFalse(spec.instance("default").includes(Environment.staging, Optional.empty())); + assertFalse(spec.instance("default").includes(Environment.prod, Optional.empty())); + assertFalse(spec.instance("default").globalServiceId().isPresent()); } @Test public void testSpecPinningMajorVersion() { String specXml = "<deployment version='1.0' major-version='6'>" + - " <test/>" + + " <instance id='default'>" + + " <test/>" + + " </instance>" + "</deployment>"; StringReader r = new StringReader(specXml); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals(specXml, spec.xmlForm()); - assertEquals(1, spec.steps().size()); + assertEquals(1, spec.instance("default").steps().size()); assertTrue(spec.majorVersion().isPresent()); assertEquals(6, (int)spec.majorVersion().get()); } @@ -66,164 +69,256 @@ public class DeploymentSpecTest { public void stagingSpec() { StringReader r = new StringReader( "<deployment version='1.0'>" + - " <staging/>" + + " <instance id='default'>" + + " <staging/>" + + " </instance>" + "</deployment>" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); - assertEquals(2, spec.steps().size()); - assertTrue(spec.steps().get(0).deploysTo(Environment.test)); - assertTrue(spec.steps().get(1).deploysTo(Environment.staging)); - assertTrue(spec.includes(Environment.test, Optional.empty())); - assertFalse(spec.includes(Environment.test, Optional.of(RegionName.from("region1")))); - assertTrue(spec.includes(Environment.staging, Optional.empty())); - assertFalse(spec.includes(Environment.prod, Optional.empty())); - assertFalse(spec.globalServiceId().isPresent()); + assertEquals(2, spec.instance("default").steps().size()); + assertTrue(spec.instance("default").steps().get(0).deploysTo(Environment.test)); + assertTrue(spec.instance("default").steps().get(1).deploysTo(Environment.staging)); + assertTrue(spec.instance("default").includes(Environment.test, Optional.empty())); + assertFalse(spec.instance("default").includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertTrue(spec.instance("default").includes(Environment.staging, Optional.empty())); + assertFalse(spec.instance("default").includes(Environment.prod, Optional.empty())); + assertFalse(spec.instance("default").globalServiceId().isPresent()); } @Test public void minimalProductionSpec() { StringReader r = new StringReader( - "<deployment version='1.0'>" + - " <prod>" + - " <region active='false'>us-east1</region>" + - " <region active='true'>us-west1</region>" + - " </prod>" + - "</deployment>" + "<deployment version='1.0'>" + + " <instance id='default'>" + + " <prod>" + + " <region active='false'>us-east1</region>" + + " <region active='true'>us-west1</region>" + + " </prod>" + + " </instance>" + + "</deployment>" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); - assertEquals(4, spec.steps().size()); + assertEquals(4, spec.instance("default").steps().size()); + + assertTrue(spec.instance("default").steps().get(0).deploysTo(Environment.test)); - assertTrue(spec.steps().get(0).deploysTo(Environment.test)); + assertTrue(spec.instance("default").steps().get(1).deploysTo(Environment.staging)); - assertTrue(spec.steps().get(1).deploysTo(Environment.staging)); + assertTrue(spec.instance("default").steps().get(2).deploysTo(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertFalse(((DeploymentSpec.DeclaredZone)spec.instance("default").steps().get(2)).active()); - assertTrue(spec.steps().get(2).deploysTo(Environment.prod, Optional.of(RegionName.from("us-east1")))); - assertFalse(((DeploymentSpec.DeclaredZone)spec.steps().get(2)).active()); + assertTrue(spec.instance("default").steps().get(3).deploysTo(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertTrue(((DeploymentSpec.DeclaredZone)spec.instance("default").steps().get(3)).active()); - assertTrue(spec.steps().get(3).deploysTo(Environment.prod, Optional.of(RegionName.from("us-west1")))); - assertTrue(((DeploymentSpec.DeclaredZone)spec.steps().get(3)).active()); + assertTrue(spec.instance("default").includes(Environment.test, Optional.empty())); + assertFalse(spec.instance("default").includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertTrue(spec.instance("default").includes(Environment.staging, Optional.empty())); + assertTrue(spec.instance("default").includes(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertTrue(spec.instance("default").includes(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertFalse(spec.instance("default").includes(Environment.prod, Optional.of(RegionName.from("no-such-region")))); + assertFalse(spec.instance("default").globalServiceId().isPresent()); - assertTrue(spec.includes(Environment.test, Optional.empty())); - assertFalse(spec.includes(Environment.test, Optional.of(RegionName.from("region1")))); - assertTrue(spec.includes(Environment.staging, Optional.empty())); - assertTrue(spec.includes(Environment.prod, Optional.of(RegionName.from("us-east1")))); - assertTrue(spec.includes(Environment.prod, Optional.of(RegionName.from("us-west1")))); - assertFalse(spec.includes(Environment.prod, Optional.of(RegionName.from("no-such-region")))); - assertFalse(spec.globalServiceId().isPresent()); - - assertEquals(DeploymentSpec.UpgradePolicy.defaultPolicy, spec.upgradePolicy()); + assertEquals(DeploymentSpec.UpgradePolicy.defaultPolicy, spec.instance("default").upgradePolicy()); } @Test public void maximalProductionSpec() { StringReader r = new StringReader( - "<deployment version='1.0'>" + - " <test/>" + - " <staging/>" + - " <prod>" + - " <region active='false'>us-east1</region>" + - " <delay hours='3' minutes='30'/>" + - " <region active='true'>us-west1</region>" + - " </prod>" + - "</deployment>" + "<deployment version='1.0'>" + + " <instance id='default'>" + // The block checked by assertCorrectFirstInstance + " <test/>" + + " <staging/>" + + " <prod>" + + " <region active='false'>us-east1</region>" + + " <delay hours='3' minutes='30'/>" + + " <region active='true'>us-west1</region>" + + " </prod>" + + " </instance>" + + "</deployment>" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); - assertEquals(5, spec.steps().size()); - assertEquals(4, spec.zones().size()); + assertCorrectFirstInstance(spec.instance("default")); + } - assertTrue(spec.steps().get(0).deploysTo(Environment.test)); + @Test + public void maximalProductionSpecMultipleInstances() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <instance id='instance1'>" + // The block checked by assertCorrectFirstInstance + " <test/>" + + " <staging/>" + + " <prod>" + + " <region active='false'>us-east1</region>" + + " <delay hours='3' minutes='30'/>" + + " <region active='true'>us-west1</region>" + + " </prod>" + + " </instance>" + + " <instance id='instance2'>" + + " <prod>" + + " <region active='true'>us-central1</region>" + + " </prod>" + + " </instance>" + + "</deployment>" + ); - assertTrue(spec.steps().get(1).deploysTo(Environment.staging)); + DeploymentSpec spec = DeploymentSpec.fromXml(r); - assertTrue(spec.steps().get(2).deploysTo(Environment.prod, Optional.of(RegionName.from("us-east1")))); - assertFalse(((DeploymentSpec.DeclaredZone)spec.steps().get(2)).active()); + assertCorrectFirstInstance(spec.instance("instance1")); - assertTrue(spec.steps().get(3) instanceof DeploymentSpec.Delay); - assertEquals(3 * 60 * 60 + 30 * 60, ((DeploymentSpec.Delay)spec.steps().get(3)).duration().getSeconds()); + DeploymentInstanceSpec instance2 = spec.instance("instance2"); + assertEquals(1, instance2.steps().size()); + assertEquals(1, instance2.zones().size()); - assertTrue(spec.steps().get(4).deploysTo(Environment.prod, Optional.of(RegionName.from("us-west1")))); - assertTrue(((DeploymentSpec.DeclaredZone)spec.steps().get(4)).active()); + assertTrue(instance2.steps().get(0).deploysTo(Environment.prod, Optional.of(RegionName.from("us-central1")))); + } - assertTrue(spec.includes(Environment.test, Optional.empty())); - assertFalse(spec.includes(Environment.test, Optional.of(RegionName.from("region1")))); - assertTrue(spec.includes(Environment.staging, Optional.empty())); - assertTrue(spec.includes(Environment.prod, Optional.of(RegionName.from("us-east1")))); - assertTrue(spec.includes(Environment.prod, Optional.of(RegionName.from("us-west1")))); - assertFalse(spec.includes(Environment.prod, Optional.of(RegionName.from("no-such-region")))); - assertFalse(spec.globalServiceId().isPresent()); + @Test + public void testMultipleInstancesShortForm() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <instance id='instance1, instance2'>" + // The block checked by assertCorrectFirstInstance + " <test/>" + + " <staging/>" + + " <prod>" + + " <region active='false'>us-east1</region>" + + " <delay hours='3' minutes='30'/>" + + " <region active='true'>us-west1</region>" + + " </prod>" + + " </instance>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + + assertCorrectFirstInstance(spec.instance("instance1")); + assertCorrectFirstInstance(spec.instance("instance2")); + } + + private void assertCorrectFirstInstance(DeploymentInstanceSpec instance) { + assertEquals(5, instance.steps().size()); + assertEquals(4, instance.zones().size()); + + assertTrue(instance.steps().get(0).deploysTo(Environment.test)); + + assertTrue(instance.steps().get(1).deploysTo(Environment.staging)); + + assertTrue(instance.steps().get(2).deploysTo(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertFalse(((DeploymentSpec.DeclaredZone)instance.steps().get(2)).active()); + + assertTrue(instance.steps().get(3) instanceof DeploymentSpec.Delay); + assertEquals(3 * 60 * 60 + 30 * 60, instance.steps().get(3).delay().getSeconds()); + + assertTrue(instance.steps().get(4).deploysTo(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertTrue(((DeploymentSpec.DeclaredZone)instance.steps().get(4)).active()); + + assertTrue(instance.includes(Environment.test, Optional.empty())); + assertFalse(instance.includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertTrue(instance.includes(Environment.staging, Optional.empty())); + assertTrue(instance.includes(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertTrue(instance.includes(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertFalse(instance.includes(Environment.prod, Optional.of(RegionName.from("no-such-region")))); + assertFalse(instance.globalServiceId().isPresent()); } @Test public void productionSpecWithGlobalServiceId() { StringReader r = new StringReader( "<deployment version='1.0'>" + - " <prod global-service-id='query'>" + - " <region active='true'>us-east-1</region>" + - " <region active='true'>us-west-1</region>" + - " </prod>" + + " <instance id='default'>" + + " <prod global-service-id='query'>" + + " <region active='true'>us-east-1</region>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + "</deployment>" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); - assertEquals(spec.globalServiceId(), Optional.of("query")); + assertEquals(spec.instance("default").globalServiceId(), Optional.of("query")); } @Test(expected=IllegalArgumentException.class) public void globalServiceIdInTest() { StringReader r = new StringReader( "<deployment version='1.0'>" + - " <test global-service-id='query' />" + + " <instance id='default'>" + + " <test global-service-id='query' />" + + " </instance>" + "</deployment>" ); - DeploymentSpec spec = DeploymentSpec.fromXml(r); + DeploymentSpec.fromXml(r); } @Test(expected=IllegalArgumentException.class) public void globalServiceIdInStaging() { StringReader r = new StringReader( "<deployment version='1.0'>" + - " <staging global-service-id='query' />" + + " <instance id='default'>" + + " <staging global-service-id='query' />" + + " </instance>" + "</deployment>" ); - DeploymentSpec spec = DeploymentSpec.fromXml(r); + DeploymentSpec.fromXml(r); } @Test public void productionSpecWithGlobalServiceIdBeforeStaging() { StringReader r = new StringReader( "<deployment>" + - " <test/>" + - " <prod global-service-id='qrs'>" + - " <region active='true'>us-west-1</region>" + - " <region active='true'>us-central-1</region>" + - " <region active='true'>us-east-3</region>" + - " </prod>" + - " <staging/>" + + " <instance id='default'>" + + " <test/>" + + " <prod global-service-id='qrs'>" + + " <region active='true'>us-west-1</region>" + + " <region active='true'>us-central-1</region>" + + " <region active='true'>us-east-3</region>" + + " </prod>" + + " <staging/>" + + " </instance>" + "</deployment>" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); - assertEquals("qrs", spec.globalServiceId().get()); + assertEquals("qrs", spec.instance("default").globalServiceId().get()); } @Test public void productionSpecWithUpgradePolicy() { StringReader r = new StringReader( "<deployment>" + - " <upgrade policy='canary'/>" + - " <prod>" + - " <region active='true'>us-west-1</region>" + - " <region active='true'>us-central-1</region>" + - " <region active='true'>us-east-3</region>" + - " </prod>" + + " <instance id='default'>" + + " <upgrade policy='canary'/>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " <region active='true'>us-central-1</region>" + + " <region active='true'>us-east-3</region>" + + " </prod>" + + " </instance>" + "</deployment>" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); - assertEquals("canary", spec.upgradePolicy().toString()); + assertEquals("canary", spec.instance("default").upgradePolicy().toString()); + } + + @Test + public void upgradePolicyDefault() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <upgrade policy='canary'/>" + + " <instance id='instance1'>" + + " <upgrade policy='conservative'/>" + + " </instance>" + + " <instance id='instance2'>" + + " </instance>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals("conservative", spec.instance("instance1").upgradePolicy().toString()); + assertEquals("canary", spec.instance("instance2").upgradePolicy().toString()); } @Test @@ -231,14 +326,16 @@ public class DeploymentSpecTest { try { StringReader r = new StringReader( "<deployment>" + - " <upgrade policy='canary'/>" + - " <prod>" + - " <region active='true'>us-west-1</region>" + - " <delay hours='23'/>" + - " <region active='true'>us-central-1</region>" + - " <delay minutes='59' seconds='61'/>" + - " <region active='true'>us-east-3</region>" + - " </prod>" + + " <instance id='default'>" + + " <upgrade policy='canary'/>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " <delay hours='23'/>" + + " <region active='true'>us-central-1</region>" + + " <delay minutes='59' seconds='61'/>" + + " <region active='true'>us-east-3</region>" + + " </prod>" + + " </instance>" + "</deployment>" ); DeploymentSpec.fromXml(r); @@ -252,7 +349,7 @@ public class DeploymentSpecTest { @Test public void testEmpty() { - assertFalse(DeploymentSpec.empty.globalServiceId().isPresent()); + assertFalse(DeploymentSpec.empty.instance("default").globalServiceId().isPresent()); assertEquals(DeploymentSpec.UpgradePolicy.defaultPolicy, DeploymentSpec.empty.upgradePolicy()); assertTrue(DeploymentSpec.empty.steps().isEmpty()); assertEquals("<deployment version='1.0'/>", DeploymentSpec.empty.xmlForm()); @@ -261,36 +358,139 @@ public class DeploymentSpecTest { @Test public void productionSpecWithParallelDeployments() { StringReader r = new StringReader( - "<deployment>\n" + - " <prod> \n" + - " <region active='true'>us-west-1</region>\n" + - " <parallel>\n" + - " <region active='true'>us-central-1</region>\n" + - " <region active='true'>us-east-3</region>\n" + - " </parallel>\n" + - " </prod>\n" + - "</deployment>" + "<deployment>" + + " <instance id='default'>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " <parallel>" + + " <region active='true'>us-central-1</region>" + + " <region active='true'>us-east-3</region>" + + " </parallel>" + + " </prod>" + + " </instance>" + + "</deployment>" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); - DeploymentSpec.ParallelZones parallelZones = ((DeploymentSpec.ParallelZones) spec.steps().get(3)); + DeploymentSpec.ParallelZones parallelZones = ((DeploymentSpec.ParallelZones) spec.instance("default").steps().get(3)); assertEquals(2, parallelZones.zones().size()); assertEquals(RegionName.from("us-central-1"), parallelZones.zones().get(0).region().get()); assertEquals(RegionName.from("us-east-3"), parallelZones.zones().get(1).region().get()); } @Test + public void testTestAndStagingOutsideAndInsideInstance() { + StringReader r = new StringReader( + "<deployment>" + + " <test/>" + + " <staging/>" + + " <instance id='instance0'>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + + " <instance id='instance1'>" + + " <test/>" + + " <staging/>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + List<DeploymentSpec.Step> steps = spec.steps(); + assertEquals(4, steps.size()); + assertEquals("test", steps.get(0).toString()); + assertEquals("staging", steps.get(1).toString()); + assertEquals("instance 'instance0'", steps.get(2).toString()); + assertEquals("instance 'instance1'", steps.get(3).toString()); + + List<DeploymentSpec.Step> instance0Steps = ((DeploymentInstanceSpec)steps.get(2)).steps(); + assertEquals(1, instance0Steps.size()); + assertEquals("prod.us-west-1", instance0Steps.get(0).toString()); + + List<DeploymentSpec.Step> instance1Steps = ((DeploymentInstanceSpec)steps.get(3)).steps(); + assertEquals(3, instance1Steps.size()); + assertEquals("test", instance1Steps.get(0).toString()); + assertEquals("staging", instance1Steps.get(1).toString()); + assertEquals("prod.us-west-1", instance1Steps.get(2).toString()); + } + + @Test + public void testParallelInstances() { + StringReader r = new StringReader( + "<deployment>" + + " <parallel>" + + " <instance id='instance0'>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + + " <instance id='instance1'>" + + " <prod>" + + " <region active='true'>us-east-3</region>" + + " </prod>" + + " </instance>" + + " </parallel>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + List<DeploymentSpec.Step> steps = spec.steps(); + assertEquals(3, steps.size()); + assertEquals("test", steps.get(0).toString()); + assertEquals("staging", steps.get(1).toString()); + assertEquals("2 parallel steps", steps.get(2).toString()); + + List<DeploymentSpec.Step> parallelSteps = steps.get(2).steps(); + assertEquals("instance 'instance0'", parallelSteps.get(0).toString()); + assertEquals("instance 'instance1'", parallelSteps.get(1).toString()); + } + + @Test + public void testInstancesWithDelay() { + StringReader r = new StringReader( + "<deployment>" + + " <instance id='instance0'>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + + " <delay hours='12'/>" + + " <instance id='instance1'>" + + " <prod>" + + " <region active='true'>us-east-3</region>" + + " </prod>" + + " </instance>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + List<DeploymentSpec.Step> steps = spec.steps(); + assertEquals(5, steps.size()); + assertEquals("test", steps.get(0).toString()); + assertEquals("staging", steps.get(1).toString()); + assertEquals("instance 'instance0'", steps.get(2).toString()); + assertEquals("delay PT12H", steps.get(3).toString()); + assertEquals("instance 'instance1'", steps.get(4).toString()); + } + + @Test public void productionSpecWithDuplicateRegions() { StringReader r = new StringReader( - "<deployment>\n" + - " <prod>\n" + - " <region active='true'>us-west-1</region>\n" + - " <parallel>\n" + - " <region active='true'>us-west-1</region>\n" + - " <region active='true'>us-central-1</region>\n" + - " <region active='true'>us-east-3</region>\n" + - " </parallel>\n" + - " </prod>\n" + - "</deployment>" + "<deployment>" + + " <instance id='default'>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " <parallel>" + + " <region active='true'>us-west-1</region>" + + " <region active='true'>us-central-1</region>" + + " <region active='true'>us-east-3</region>" + + " </parallel>" + + " </prod>" + + " </instance>" + + "</deployment>" ); try { DeploymentSpec.fromXml(r); @@ -303,197 +503,328 @@ public class DeploymentSpecTest { @Test(expected = IllegalArgumentException.class) public void deploymentSpecWithIllegallyOrderedDeploymentSpec1() { StringReader r = new StringReader( - "<deployment>\n" + - " <block-change days='sat' hours='10' time-zone='CET'/>\n" + - " <prod>\n" + - " <region active='true'>us-west-1</region>\n" + - " </prod>\n" + - " <block-change days='mon,tue' hours='15-16'/>\n" + + "<deployment>" + + " <instance id='default'>" + + " <block-change days='sat' hours='10' time-zone='CET'/>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " <block-change days='mon,tue' hours='15-16'/>" + + " </instance>" + "</deployment>" ); - DeploymentSpec spec = DeploymentSpec.fromXml(r); + DeploymentSpec.fromXml(r); } @Test(expected = IllegalArgumentException.class) public void deploymentSpecWithIllegallyOrderedDeploymentSpec2() { StringReader r = new StringReader( "<deployment>\n" + - " <block-change days='sat' hours='10' time-zone='CET'/>\n" + - " <test/>\n" + - " <prod>\n" + - " <region active='true'>us-west-1</region>\n" + - " </prod>\n" + + " <instance id='default'>" + + " <block-change days='sat' hours='10' time-zone='CET'/>" + + " <test/>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + "</deployment>" ); - DeploymentSpec spec = DeploymentSpec.fromXml(r); + DeploymentSpec.fromXml(r); } @Test public void deploymentSpecWithChangeBlocker() { StringReader r = new StringReader( - "<deployment>\n" + - " <block-change revision='false' days='mon,tue' hours='15-16'/>\n" + - " <block-change days='sat' hours='10' time-zone='CET'/>\n" + - " <prod>\n" + - " <region active='true'>us-west-1</region>\n" + - " </prod>\n" + + "<deployment>" + + " <instance id='default'>" + + " <block-change revision='false' days='mon,tue' hours='15-16'/>" + + " <block-change days='sat' hours='10' time-zone='CET'/>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + "</deployment>" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); - assertEquals(2, spec.changeBlocker().size()); - assertTrue(spec.changeBlocker().get(0).blocksVersions()); - assertFalse(spec.changeBlocker().get(0).blocksRevisions()); - assertEquals(ZoneId.of("UTC"), spec.changeBlocker().get(0).window().zone()); + assertEquals(2, spec.instance("default").changeBlocker().size()); + assertTrue(spec.instance("default").changeBlocker().get(0).blocksVersions()); + assertFalse(spec.instance("default").changeBlocker().get(0).blocksRevisions()); + assertEquals(ZoneId.of("UTC"), spec.instance("default").changeBlocker().get(0).window().zone()); - assertTrue(spec.changeBlocker().get(1).blocksVersions()); - assertTrue(spec.changeBlocker().get(1).blocksRevisions()); - assertEquals(ZoneId.of("CET"), spec.changeBlocker().get(1).window().zone()); + assertTrue(spec.instance("default").changeBlocker().get(1).blocksVersions()); + assertTrue(spec.instance("default").changeBlocker().get(1).blocksRevisions()); + assertEquals(ZoneId.of("CET"), spec.instance("default").changeBlocker().get(1).window().zone()); - assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-18T14:15:30.00Z"))); - assertFalse(spec.canUpgradeAt(Instant.parse("2017-09-18T15:15:30.00Z"))); - assertFalse(spec.canUpgradeAt(Instant.parse("2017-09-18T16:15:30.00Z"))); - assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-18T17:15:30.00Z"))); + assertTrue(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-18T14:15:30.00Z"))); + assertFalse(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-18T15:15:30.00Z"))); + assertFalse(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-18T16:15:30.00Z"))); + assertTrue(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-18T17:15:30.00Z"))); - assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-23T09:15:30.00Z"))); - assertFalse(spec.canUpgradeAt(Instant.parse("2017-09-23T08:15:30.00Z"))); // 10 in CET - assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-23T10:15:30.00Z"))); + assertTrue(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-23T09:15:30.00Z"))); + assertFalse(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-23T08:15:30.00Z"))); // 10 in CET + assertTrue(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-23T10:15:30.00Z"))); + } + + @Test + public void testChangeBlockerInheritance() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <block-change revision='false' days='mon,tue' hours='15-16'/>" + + " <instance id='instance1'>" + + " <block-change days='sat' hours='10' time-zone='CET'/>" + + " </instance>" + + " <instance id='instance2'>" + + " </instance>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + + String inheritedChangeBlocker = "change blocker revision=false version=true window=time window for hour(s) [15, 16] on [monday, tuesday] in UTC"; + + assertEquals(2, spec.instance("instance1").changeBlocker().size()); + assertEquals(inheritedChangeBlocker, spec.instance("instance1").changeBlocker().get(0).toString()); + assertEquals("change blocker revision=true version=true window=time window for hour(s) [10] on [saturday] in CET", + spec.instance("instance1").changeBlocker().get(1).toString()); + + assertEquals(1, spec.instance("instance2").changeBlocker().size()); + assertEquals(inheritedChangeBlocker, spec.instance("instance2").changeBlocker().get(0).toString()); } @Test public void athenz_config_is_read_from_deployment() { StringReader r = new StringReader( - "<deployment athenz-domain='domain' athenz-service='service'>\n" + - " <prod>\n" + - " <region active='true'>us-west-1</region>\n" + - " </prod>\n" + + "<deployment athenz-domain='domain' athenz-service='service'>" + + " <instance id='instance1'>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + "</deployment>" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); - assertEquals(spec.athenzDomain().get().value(), "domain"); - assertEquals(spec.athenzService(Environment.prod, RegionName.from("us-west-1")).get().value(), "service"); + assertEquals(spec.instance("instance1").athenzDomain().get().value(), "domain"); + assertEquals(spec.instance("instance1").athenzService(Environment.prod, RegionName.from("us-west-1")).get().value(), "service"); + } + + @Test + public void athenz_config_is_read_from_instance() { + StringReader r = new StringReader( + "<deployment>" + + " <instance id='default' athenz-domain='domain' athenz-service='service'>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(spec.instance("default").athenzDomain().get().value(), "domain"); + assertEquals(spec.instance("default").athenzService(Environment.prod, RegionName.from("us-west-1")).get().value(), "service"); } @Test public void athenz_service_is_overridden_from_environment() { StringReader r = new StringReader( - "<deployment athenz-domain='domain' athenz-service='service'>\n" + - " <test/>\n" + - " <prod athenz-service='prod-service'>\n" + - " <region active='true'>us-west-1</region>\n" + - " </prod>\n" + + "<deployment athenz-domain='domain' athenz-service='service'>" + + " <instance id='default' athenz-domain='domain' athenz-service='service'>" + + " <test/>" + + " <prod athenz-service='prod-service'>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + "</deployment>" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); - assertEquals(spec.athenzDomain().get().value(), "domain"); - assertEquals(spec.athenzService(Environment.prod, RegionName.from("us-west-1")).get().value(), "prod-service"); + assertEquals(spec.instance("default").athenzDomain().get().value(), "domain"); + assertEquals(spec.instance("default").athenzService(Environment.prod, RegionName.from("us-west-1")).get().value(), "prod-service"); } @Test(expected = IllegalArgumentException.class) public void it_fails_when_athenz_service_is_not_defined() { StringReader r = new StringReader( - "<deployment athenz-domain='domain'>\n" + - " <prod>\n" + - " <region active='true'>us-west-1</region>\n" + - " </prod>\n" + + "<deployment>" + + " <instance id='default' athenz-domain='domain'>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + "</deployment>" ); - DeploymentSpec spec = DeploymentSpec.fromXml(r); + DeploymentSpec.fromXml(r); } @Test(expected = IllegalArgumentException.class) public void it_fails_when_athenz_service_is_configured_but_not_athenz_domain() { StringReader r = new StringReader( - "<deployment>\n" + - " <prod athenz-service='service'>\n" + - " <region active='true'>us-west-1</region>\n" + - " </prod>\n" + + "<deployment>" + + " <instance id='default'>" + + " <prod athenz-service='service'>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + " </instance>" + "</deployment>" ); - DeploymentSpec spec = DeploymentSpec.fromXml(r); + DeploymentSpec.fromXml(r); } @Test public void noNotifications() { assertEquals(Notifications.none(), - DeploymentSpec.fromXml("<deployment />").notifications()); + DeploymentSpec.fromXml("<deployment>" + + " <instance id='default'/>" + + "</deployment>").instance("default").notifications()); } @Test public void emptyNotifications() { - DeploymentSpec spec = DeploymentSpec.fromXml("<deployment>\n" + - " <notifications />" + + DeploymentSpec spec = DeploymentSpec.fromXml("<deployment>" + + " <instance id='default'>" + + " <notifications/>" + + " </instance>" + "</deployment>"); - assertEquals(Notifications.none(), - spec.notifications()); + assertEquals(Notifications.none(), spec.instance("default").notifications()); } @Test public void someNotifications() { DeploymentSpec spec = DeploymentSpec.fromXml("<deployment>\n" + - " <notifications when=\"failing\">\n" + - " <email role=\"author\"/>\n" + - " <email address=\"john@dev\" when=\"failing-commit\"/>\n" + - " <email address=\"jane@dev\"/>\n" + - " </notifications>\n" + + " <instance id='default'>" + + " <notifications when=\"failing\">" + + " <email role=\"author\"/>" + + " <email address=\"john@dev\" when=\"failing-commit\"/>" + + " <email address=\"jane@dev\"/>" + + " </notifications>" + + " </instance>" + "</deployment>"); - assertEquals(ImmutableSet.of(author), spec.notifications().emailRolesFor(failing)); - assertEquals(ImmutableSet.of(author), spec.notifications().emailRolesFor(failingCommit)); - assertEquals(ImmutableSet.of("john@dev", "jane@dev"), spec.notifications().emailAddressesFor(failingCommit)); - assertEquals(ImmutableSet.of("jane@dev"), spec.notifications().emailAddressesFor(failing)); + assertEquals(ImmutableSet.of(author), spec.instance("default").notifications().emailRolesFor(failing)); + assertEquals(ImmutableSet.of(author), spec.instance("default").notifications().emailRolesFor(failingCommit)); + assertEquals(ImmutableSet.of("john@dev", "jane@dev"), spec.instance("default").notifications().emailAddressesFor(failingCommit)); + assertEquals(ImmutableSet.of("jane@dev"), spec.instance("default").notifications().emailAddressesFor(failing)); + } + + @Test + public void notificationsWithMultipleInstances() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <instance id='instance1'>" + + " <notifications when=\"failing\">" + + " <email role=\"author\"/>" + + " <email address=\"john@operator\"/>" + + " </notifications>" + + " </instance>" + + " <instance id='instance2'>" + + " <notifications when=\"failing-commit\">" + + " <email role=\"author\"/>" + + " <email address=\"mary@dev\"/>" + + " </notifications>" + + " </instance>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + DeploymentInstanceSpec instance1 = spec.instance("instance1"); + assertEquals(Set.of(author), instance1.notifications().emailRolesFor(failing)); + assertEquals(Set.of("john@operator"), instance1.notifications().emailAddressesFor(failing)); + + DeploymentInstanceSpec instance2 = spec.instance("instance2"); + assertEquals(Set.of(author), instance2.notifications().emailRolesFor(failingCommit)); + assertEquals(Set.of("mary@dev"), instance2.notifications().emailAddressesFor(failingCommit)); + } + + @Test + public void notificationsDefault() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <notifications when=\"failing-commit\">" + + " <email role=\"author\"/>" + + " <email address=\"mary@dev\"/>" + + " </notifications>" + + " <instance id='instance1'>" + + " <notifications when=\"failing\">" + + " <email role=\"author\"/>" + + " <email address=\"john@operator\"/>" + + " </notifications>" + + " </instance>" + + " <instance id='instance2'>" + + " </instance>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + DeploymentInstanceSpec instance1 = spec.instance("instance1"); + assertEquals(Set.of(author), instance1.notifications().emailRolesFor(failing)); + assertEquals(Set.of("john@operator"), instance1.notifications().emailAddressesFor(failing)); + + DeploymentInstanceSpec instance2 = spec.instance("instance2"); + assertEquals(Set.of(author), instance2.notifications().emailRolesFor(failingCommit)); + assertEquals(Set.of("mary@dev"), instance2.notifications().emailAddressesFor(failingCommit)); } @Test public void customTesterFlavor() { - DeploymentSpec spec = DeploymentSpec.fromXml("<deployment>\n" + - " <test tester-flavor=\"d-1-4-20\" />\n" + - " <prod tester-flavor=\"d-2-8-50\">\n" + - " <region active=\"false\">us-north-7</region>\n" + - " </prod>\n" + + DeploymentSpec spec = DeploymentSpec.fromXml("<deployment>" + + " <instance id='default'>" + + " <test tester-flavor=\"d-1-4-20\" />" + + " <prod tester-flavor=\"d-2-8-50\">" + + " <region active=\"false\">us-north-7</region>" + + " </prod>" + + " </instance>" + "</deployment>"); - assertEquals(Optional.of("d-1-4-20"), spec.steps().get(0).zones().get(0).testerFlavor()); - assertEquals(Optional.empty(), spec.steps().get(1).zones().get(0).testerFlavor()); - assertEquals(Optional.of("d-2-8-50"), spec.steps().get(2).zones().get(0).testerFlavor()); + assertEquals(Optional.of("d-1-4-20"), spec.instance("default").steps().get(0).zones().get(0).testerFlavor()); + assertEquals(Optional.empty(), spec.instance("default").steps().get(1).zones().get(0).testerFlavor()); + assertEquals(Optional.of("d-2-8-50"), spec.instance("default").steps().get(2).zones().get(0).testerFlavor()); } @Test public void noEndpoints() { - assertEquals(Collections.emptyList(), DeploymentSpec.fromXml("<deployment />").endpoints()); + assertEquals(Collections.emptyList(), + DeploymentSpec.fromXml("<deployment>" + + " <instance id='default'/>" + + "</deployment>").instance("default").endpoints()); } @Test public void emptyEndpoints() { - final var spec = DeploymentSpec.fromXml("<deployment><endpoints/></deployment>"); - assertEquals(Collections.emptyList(), spec.endpoints()); + var spec = DeploymentSpec.fromXml("<deployment>" + + " <instance id='default'>" + + " <endpoints/>" + + " </instance>" + + "</deployment>"); + assertEquals(Collections.emptyList(), spec.instance("default").endpoints()); } @Test public void someEndpoints() { - final var spec = DeploymentSpec.fromXml("" + - "<deployment>" + - " <prod>" + - " <region active=\"true\">us-east</region>" + - " </prod>" + - " <endpoints>" + - " <endpoint id=\"foo\" container-id=\"bar\">" + - " <region>us-east</region>" + - " </endpoint>" + - " <endpoint id=\"nalle\" container-id=\"frosk\" />" + - " <endpoint container-id=\"quux\" />" + - " </endpoints>" + - "</deployment>"); + var spec = DeploymentSpec.fromXml("" + + "<deployment>" + + " <instance id='default'>" + + " <prod>" + + " <region active=\"true\">us-east</region>" + + " </prod>" + + " <endpoints>" + + " <endpoint id=\"foo\" container-id=\"bar\">" + + " <region>us-east</region>" + + " </endpoint>" + + " <endpoint id=\"nalle\" container-id=\"frosk\" />" + + " <endpoint container-id=\"quux\" />" + + " </endpoints>" + + " </instance>" + + "</deployment>"); assertEquals( List.of("foo", "nalle", "default"), - spec.endpoints().stream().map(Endpoint::endpointId).collect(Collectors.toList()) + spec.instance("default").endpoints().stream().map(Endpoint::endpointId).collect(Collectors.toList()) ); assertEquals( List.of("bar", "frosk", "quux"), - spec.endpoints().stream().map(Endpoint::containerId).collect(Collectors.toList()) + spec.instance("default").endpoints().stream().map(Endpoint::containerId).collect(Collectors.toList()) ); - assertEquals(Set.of(RegionName.from("us-east")), spec.endpoints().get(0).regions()); + assertEquals(Set.of(RegionName.from("us-east")), spec.instance("default").endpoints().get(0).regions()); } + @Test public void invalidEndpoints() { assertInvalid("<endpoint id='FOO' container-id='qrs'/>"); // Uppercase @@ -520,19 +851,21 @@ public class DeploymentSpecTest { @Test public void endpointDefaultRegions() { var spec = DeploymentSpec.fromXml("" + - "<deployment>" + - " <prod>" + - " <region active=\"true\">us-east</region>" + - " <region active=\"true\">us-west</region>" + - " </prod>" + - " <endpoints>" + - " <endpoint id=\"foo\" container-id=\"bar\">" + - " <region>us-east</region>" + - " </endpoint>" + - " <endpoint id=\"nalle\" container-id=\"frosk\" />" + - " <endpoint container-id=\"quux\" />" + - " </endpoints>" + - "</deployment>"); + "<deployment>" + + " <instance id='default'>" + + " <prod>" + + " <region active=\"true\">us-east</region>" + + " <region active=\"true\">us-west</region>" + + " </prod>" + + " <endpoints>" + + " <endpoint id=\"foo\" container-id=\"bar\">" + + " <region>us-east</region>" + + " </endpoint>" + + " <endpoint id=\"nalle\" container-id=\"frosk\" />" + + " <endpoint container-id=\"quux\" />" + + " </endpoints>" + + " </instance>" + + "</deployment>"); assertEquals(Set.of("us-east"), endpointRegions("foo", spec)); assertEquals(Set.of("us-east", "us-west"), endpointRegions("nalle", spec)); @@ -547,7 +880,7 @@ public class DeploymentSpecTest { } private static Set<String> endpointRegions(String endpointId, DeploymentSpec spec) { - return spec.endpoints().stream() + return spec.instance("default").endpoints().stream() .filter(endpoint -> endpoint.endpointId().equals(endpointId)) .flatMap(endpoint -> endpoint.regions().stream()) .map(RegionName::value) @@ -556,15 +889,17 @@ public class DeploymentSpecTest { private static List<String> endpointIds(String endpointTag) { var xml = "<deployment>" + - " <prod>" + - " <region active=\"true\">us-east</region>" + - " </prod>" + - " <endpoints>" + + " <instance id='default'>" + + " <prod>" + + " <region active=\"true\">us-east</region>" + + " </prod>" + + " <endpoints>" + endpointTag + - " </endpoints>" + + " </endpoints>" + + " </instance>" + "</deployment>"; - return DeploymentSpec.fromXml(xml).endpoints().stream() + return DeploymentSpec.fromXml(xml).instance("default").endpoints().stream() .map(Endpoint::endpointId) .collect(Collectors.toList()); } diff --git a/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecWithoutInstanceTest.java b/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecWithoutInstanceTest.java new file mode 100644 index 00000000000..33ef3f4bea8 --- /dev/null +++ b/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecWithoutInstanceTest.java @@ -0,0 +1,526 @@ +// 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.api; + +import com.google.common.collect.ImmutableSet; +import com.yahoo.config.provision.Environment; +import com.yahoo.config.provision.RegionName; +import org.junit.Test; + +import java.io.StringReader; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.yahoo.config.application.api.Notifications.Role.author; +import static com.yahoo.config.application.api.Notifications.When.failing; +import static com.yahoo.config.application.api.Notifications.When.failingCommit; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * @author bratseth + */ +public class DeploymentSpecWithoutInstanceTest { + + @Test + public void testSpec() { + String specXml = "<deployment version='1.0'>" + + " <test/>" + + "</deployment>"; + + StringReader r = new StringReader(specXml); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(specXml, spec.xmlForm()); + assertEquals(1, spec.steps().size()); + assertFalse(spec.majorVersion().isPresent()); + assertTrue(spec.steps().get(0).deploysTo(Environment.test)); + assertTrue(spec.instance("default").includes(Environment.test, Optional.empty())); + assertFalse(spec.instance("default").includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertFalse(spec.instance("default").includes(Environment.staging, Optional.empty())); + assertFalse(spec.instance("default").includes(Environment.prod, Optional.empty())); + assertFalse(spec.instance("default").globalServiceId().isPresent()); + } + + @Test + public void testSpecPinningMajorVersion() { + String specXml = "<deployment version='1.0' major-version='6'>" + + " <test/>" + + "</deployment>"; + + StringReader r = new StringReader(specXml); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(specXml, spec.xmlForm()); + assertEquals(1, spec.steps().size()); + assertTrue(spec.majorVersion().isPresent()); + assertEquals(6, (int)spec.majorVersion().get()); + } + + @Test + public void stagingSpec() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <staging/>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(2, spec.steps().size()); + assertTrue(spec.instance("default").steps().get(0).deploysTo(Environment.test)); + assertTrue(spec.instance("default").steps().get(1).deploysTo(Environment.staging)); + assertTrue(spec.instance("default").includes(Environment.test, Optional.empty())); + assertFalse(spec.instance("default").includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertTrue(spec.instance("default").includes(Environment.staging, Optional.empty())); + assertFalse(spec.instance("default").includes(Environment.prod, Optional.empty())); + assertFalse(spec.instance("default").globalServiceId().isPresent()); + } + + @Test + public void minimalProductionSpec() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <prod>" + + " <region active='false'>us-east1</region>" + + " <region active='true'>us-west1</region>" + + " </prod>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(4, spec.instance("default").steps().size()); + + assertTrue(spec.instance("default").steps().get(0).deploysTo(Environment.test)); + + assertTrue(spec.instance("default").steps().get(1).deploysTo(Environment.staging)); + + assertTrue(spec.instance("default").steps().get(2).deploysTo(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertFalse(((DeploymentSpec.DeclaredZone)spec.instance("default").steps().get(2)).active()); + + assertTrue(spec.instance("default").steps().get(3).deploysTo(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertTrue(((DeploymentSpec.DeclaredZone)spec.instance("default").steps().get(3)).active()); + + assertTrue(spec.instance("default").includes(Environment.test, Optional.empty())); + assertFalse(spec.instance("default").includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertTrue(spec.instance("default").includes(Environment.staging, Optional.empty())); + assertTrue(spec.instance("default").includes(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertTrue(spec.instance("default").includes(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertFalse(spec.instance("default").includes(Environment.prod, Optional.of(RegionName.from("no-such-region")))); + assertFalse(spec.instance("default").globalServiceId().isPresent()); + + assertEquals(DeploymentSpec.UpgradePolicy.defaultPolicy, spec.instance("default").upgradePolicy()); + } + + @Test + public void maximalProductionSpec() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <test/>" + + " <staging/>" + + " <prod>" + + " <region active='false'>us-east1</region>" + + " <delay hours='3' minutes='30'/>" + + " <region active='true'>us-west1</region>" + + " </prod>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(5, spec.instance("default").steps().size()); + assertEquals(4, spec.instance("default").zones().size()); + + assertTrue(spec.instance("default").steps().get(0).deploysTo(Environment.test)); + + assertTrue(spec.instance("default").steps().get(1).deploysTo(Environment.staging)); + + assertTrue(spec.instance("default").steps().get(2).deploysTo(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertFalse(((DeploymentSpec.DeclaredZone)spec.instance("default").steps().get(2)).active()); + + assertTrue(spec.instance("default").steps().get(3) instanceof DeploymentSpec.Delay); + assertEquals(3 * 60 * 60 + 30 * 60, spec.instance("default").steps().get(3).delay().getSeconds()); + + assertTrue(spec.instance("default").steps().get(4).deploysTo(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertTrue(((DeploymentSpec.DeclaredZone)spec.instance("default").steps().get(4)).active()); + + assertTrue(spec.instance("default").includes(Environment.test, Optional.empty())); + assertFalse(spec.instance("default").includes(Environment.test, Optional.of(RegionName.from("region1")))); + assertTrue(spec.instance("default").includes(Environment.staging, Optional.empty())); + assertTrue(spec.instance("default").includes(Environment.prod, Optional.of(RegionName.from("us-east1")))); + assertTrue(spec.instance("default").includes(Environment.prod, Optional.of(RegionName.from("us-west1")))); + assertFalse(spec.instance("default").includes(Environment.prod, Optional.of(RegionName.from("no-such-region")))); + assertFalse(spec.instance("default").globalServiceId().isPresent()); + } + + @Test + public void productionSpecWithGlobalServiceId() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <prod global-service-id='query'>" + + " <region active='true'>us-east-1</region>" + + " <region active='true'>us-west-1</region>" + + " </prod>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(spec.instance("default").globalServiceId(), Optional.of("query")); + } + + @Test(expected=IllegalArgumentException.class) + public void globalServiceIdInTest() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <test global-service-id='query' />" + + "</deployment>" + ); + DeploymentSpec.fromXml(r); + } + + @Test(expected=IllegalArgumentException.class) + public void globalServiceIdInStaging() { + StringReader r = new StringReader( + "<deployment version='1.0'>" + + " <staging global-service-id='query' />" + + "</deployment>" + ); + DeploymentSpec.fromXml(r); + } + + @Test + public void productionSpecWithGlobalServiceIdBeforeStaging() { + StringReader r = new StringReader( + "<deployment>" + + " <test/>" + + " <prod global-service-id='qrs'>" + + " <region active='true'>us-west-1</region>" + + " <region active='true'>us-central-1</region>" + + " <region active='true'>us-east-3</region>" + + " </prod>" + + " <staging/>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals("qrs", spec.instance("default").globalServiceId().get()); + } + + @Test + public void productionSpecWithUpgradePolicy() { + StringReader r = new StringReader( + "<deployment>" + + " <upgrade policy='canary'/>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " <region active='true'>us-central-1</region>" + + " <region active='true'>us-east-3</region>" + + " </prod>" + + "</deployment>" + ); + + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals("canary", spec.instance("default").upgradePolicy().toString()); + } + + @Test + public void maxDelayExceeded() { + try { + StringReader r = new StringReader( + "<deployment>" + + " <upgrade policy='canary'/>" + + " <prod>" + + " <region active='true'>us-west-1</region>" + + " <delay hours='23'/>" + + " <region active='true'>us-central-1</region>" + + " <delay minutes='59' seconds='61'/>" + + " <region active='true'>us-east-3</region>" + + " </prod>" + + "</deployment>" + ); + DeploymentSpec.fromXml(r); + fail("Expected exception due to exceeding the max total delay"); + } + catch (IllegalArgumentException e) { + // success + assertEquals("The total delay specified is PT24H1S but max 24 hours is allowed", e.getMessage()); + } + } + + @Test + public void testEmpty() { + assertFalse(DeploymentSpec.empty.instance("default").globalServiceId().isPresent()); + assertEquals(DeploymentSpec.UpgradePolicy.defaultPolicy, DeploymentSpec.empty.upgradePolicy()); + assertTrue(DeploymentSpec.empty.steps().isEmpty()); + assertEquals("<deployment version='1.0'/>", DeploymentSpec.empty.xmlForm()); + } + + @Test + public void productionSpecWithParallelDeployments() { + StringReader r = new StringReader( + "<deployment>\n" + + " <prod> \n" + + " <region active='true'>us-west-1</region>\n" + + " <parallel>\n" + + " <region active='true'>us-central-1</region>\n" + + " <region active='true'>us-east-3</region>\n" + + " </parallel>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + DeploymentSpec.ParallelZones parallelZones = ((DeploymentSpec.ParallelZones) spec.instance("default").steps().get(3)); + assertEquals(2, parallelZones.zones().size()); + assertEquals(RegionName.from("us-central-1"), parallelZones.zones().get(0).region().get()); + assertEquals(RegionName.from("us-east-3"), parallelZones.zones().get(1).region().get()); + } + + @Test + public void productionSpecWithDuplicateRegions() { + StringReader r = new StringReader( + "<deployment>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " <parallel>\n" + + " <region active='true'>us-west-1</region>\n" + + " <region active='true'>us-central-1</region>\n" + + " <region active='true'>us-east-3</region>\n" + + " </parallel>\n" + + " </prod>\n" + + "</deployment>" + ); + try { + DeploymentSpec.fromXml(r); + fail("Expected exception"); + } catch (IllegalArgumentException e) { + assertEquals("prod.us-west-1 is listed twice in deployment.xml", e.getMessage()); + } + } + + @Test(expected = IllegalArgumentException.class) + public void deploymentSpecWithIllegallyOrderedDeploymentSpec1() { + StringReader r = new StringReader( + "<deployment>\n" + + " <block-change days='sat' hours='10' time-zone='CET'/>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + " <block-change days='mon,tue' hours='15-16'/>\n" + + "</deployment>" + ); + DeploymentSpec.fromXml(r); + } + + @Test(expected = IllegalArgumentException.class) + public void deploymentSpecWithIllegallyOrderedDeploymentSpec2() { + StringReader r = new StringReader( + "<deployment>\n" + + " <block-change days='sat' hours='10' time-zone='CET'/>\n" + + " <test/>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec.fromXml(r); + } + + @Test + public void deploymentSpecWithChangeBlocker() { + StringReader r = new StringReader( + "<deployment>\n" + + " <block-change revision='false' days='mon,tue' hours='15-16'/>\n" + + " <block-change days='sat' hours='10' time-zone='CET'/>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(2, spec.instance("default").changeBlocker().size()); + assertTrue(spec.instance("default").changeBlocker().get(0).blocksVersions()); + assertFalse(spec.instance("default").changeBlocker().get(0).blocksRevisions()); + assertEquals(ZoneId.of("UTC"), spec.instance("default").changeBlocker().get(0).window().zone()); + + assertTrue(spec.instance("default").changeBlocker().get(1).blocksVersions()); + assertTrue(spec.instance("default").changeBlocker().get(1).blocksRevisions()); + assertEquals(ZoneId.of("CET"), spec.instance("default").changeBlocker().get(1).window().zone()); + + assertTrue(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-18T14:15:30.00Z"))); + assertFalse(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-18T15:15:30.00Z"))); + assertFalse(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-18T16:15:30.00Z"))); + assertTrue(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-18T17:15:30.00Z"))); + + assertTrue(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-23T09:15:30.00Z"))); + assertFalse(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-23T08:15:30.00Z"))); // 10 in CET + assertTrue(spec.instance("default").canUpgradeAt(Instant.parse("2017-09-23T10:15:30.00Z"))); + } + + @Test + public void athenz_config_is_read_from_deployment() { + StringReader r = new StringReader( + "<deployment athenz-domain='domain' athenz-service='service'>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(spec.instance("default").athenzDomain().get().value(), "domain"); + assertEquals(spec.instance("default").athenzService(Environment.prod, RegionName.from("us-west-1")).get().value(), "service"); + } + + @Test + public void athenz_service_is_overridden_from_environment() { + StringReader r = new StringReader( + "<deployment athenz-domain='domain' athenz-service='service'>\n" + + " <test/>\n" + + " <prod athenz-service='prod-service'>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(spec.instance("default").athenzDomain().get().value(), "domain"); + assertEquals(spec.instance("default").athenzService(Environment.prod, RegionName.from("us-west-1")).get().value(), "prod-service"); + } + + @Test(expected = IllegalArgumentException.class) + public void it_fails_when_athenz_service_is_not_defined() { + StringReader r = new StringReader( + "<deployment athenz-domain='domain'>\n" + + " <prod>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec.fromXml(r); + } + + @Test(expected = IllegalArgumentException.class) + public void it_fails_when_athenz_service_is_configured_but_not_athenz_domain() { + StringReader r = new StringReader( + "<deployment>\n" + + " <prod athenz-service='service'>\n" + + " <region active='true'>us-west-1</region>\n" + + " </prod>\n" + + "</deployment>" + ); + DeploymentSpec.fromXml(r); + } + + @Test + public void noNotifications() { + assertEquals(Notifications.none(), + DeploymentSpec.fromXml("<deployment />").instance("default").notifications()); + } + + @Test + public void emptyNotifications() { + DeploymentSpec spec = DeploymentSpec.fromXml("<deployment>\n" + + " <notifications />" + + "</deployment>"); + assertEquals(Notifications.none(), spec.instance("default").notifications()); + } + + @Test + public void someNotifications() { + DeploymentSpec spec = DeploymentSpec.fromXml("<deployment>\n" + + " <notifications when=\"failing\">\n" + + " <email role=\"author\"/>\n" + + " <email address=\"john@dev\" when=\"failing-commit\"/>\n" + + " <email address=\"jane@dev\"/>\n" + + " </notifications>\n" + + "</deployment>"); + assertEquals(ImmutableSet.of(author), spec.instance("default").notifications().emailRolesFor(failing)); + assertEquals(ImmutableSet.of(author), spec.instance("default").notifications().emailRolesFor(failingCommit)); + assertEquals(ImmutableSet.of("john@dev", "jane@dev"), spec.instance("default").notifications().emailAddressesFor(failingCommit)); + assertEquals(ImmutableSet.of("jane@dev"), spec.instance("default").notifications().emailAddressesFor(failing)); + } + + @Test + public void customTesterFlavor() { + DeploymentSpec spec = DeploymentSpec.fromXml("<deployment>\n" + + " <test tester-flavor=\"d-1-4-20\" />\n" + + " <prod tester-flavor=\"d-2-8-50\">\n" + + " <region active=\"false\">us-north-7</region>\n" + + " </prod>\n" + + "</deployment>"); + assertEquals(Optional.of("d-1-4-20"), spec.instance("default").steps().get(0).zones().get(0).testerFlavor()); + assertEquals(Optional.empty(), spec.instance("default").steps().get(1).zones().get(0).testerFlavor()); + assertEquals(Optional.of("d-2-8-50"), spec.instance("default").steps().get(2).zones().get(0).testerFlavor()); + } + + @Test + public void noEndpoints() { + assertEquals(Collections.emptyList(), DeploymentSpec.fromXml("<deployment />").instance("default").endpoints()); + } + + @Test + public void emptyEndpoints() { + var spec = DeploymentSpec.fromXml("<deployment><endpoints/></deployment>"); + assertEquals(Collections.emptyList(), spec.instance("default").endpoints()); + } + + @Test + public void someEndpoints() { + var spec = DeploymentSpec.fromXml("" + + "<deployment>" + + " <prod>" + + " <region active=\"true\">us-east</region>" + + " </prod>" + + " <endpoints>" + + " <endpoint id=\"foo\" container-id=\"bar\">" + + " <region>us-east</region>" + + " </endpoint>" + + " <endpoint id=\"nalle\" container-id=\"frosk\" />" + + " <endpoint container-id=\"quux\" />" + + " </endpoints>" + + "</deployment>"); + + assertEquals( + List.of("foo", "nalle", "default"), + spec.instance("default").endpoints().stream().map(Endpoint::endpointId).collect(Collectors.toList()) + ); + + assertEquals( + List.of("bar", "frosk", "quux"), + spec.instance("default").endpoints().stream().map(Endpoint::containerId).collect(Collectors.toList()) + ); + + assertEquals(Set.of(RegionName.from("us-east")), spec.instance("default").endpoints().get(0).regions()); + } + + @Test + public void endpointDefaultRegions() { + var spec = DeploymentSpec.fromXml("" + + "<deployment>" + + " <prod>" + + " <region active=\"true\">us-east</region>" + + " <region active=\"true\">us-west</region>" + + " </prod>" + + " <endpoints>" + + " <endpoint id=\"foo\" container-id=\"bar\">" + + " <region>us-east</region>" + + " </endpoint>" + + " <endpoint id=\"nalle\" container-id=\"frosk\" />" + + " <endpoint container-id=\"quux\" />" + + " </endpoints>" + + "</deployment>"); + + assertEquals(Set.of("us-east"), endpointRegions("foo", spec)); + assertEquals(Set.of("us-east", "us-west"), endpointRegions("nalle", spec)); + assertEquals(Set.of("us-east", "us-west"), endpointRegions("default", spec)); + } + + private static Set<String> endpointRegions(String endpointId, DeploymentSpec spec) { + return spec.instance("default").endpoints().stream() + .filter(endpoint -> endpoint.endpointId().equals(endpointId)) + .flatMap(endpoint -> endpoint.regions().stream()) + .map(RegionName::value) + .collect(Collectors.toSet()); + } + +} diff --git a/config-model/src/main/java/com/yahoo/vespa/model/application/validation/DeploymentFileValidator.java b/config-model/src/main/java/com/yahoo/vespa/model/application/validation/DeploymentFileValidator.java deleted file mode 100644 index 7757a8d4748..00000000000 --- a/config-model/src/main/java/com/yahoo/vespa/model/application/validation/DeploymentFileValidator.java +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. -package com.yahoo.vespa.model.application.validation; - -import com.yahoo.config.application.api.DeploymentSpec; -import com.yahoo.config.model.deploy.DeployState; -import com.yahoo.vespa.model.VespaModel; -import com.yahoo.vespa.model.container.ContainerCluster; -import com.yahoo.vespa.model.container.ContainerModel; - -import java.io.Reader; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * Validates that deployment file (deployment.xml) has valid values (for now - * only global-service-id is validated) - * - * @author hmusum - */ -public class DeploymentFileValidator extends Validator { - - @Override - public void validate(VespaModel model, DeployState deployState) { - Optional<Reader> deployment = deployState.getApplicationPackage().getDeployment(); - - if (deployment.isPresent()) { - Reader deploymentReader = deployment.get(); - DeploymentSpec deploymentSpec = DeploymentSpec.fromXml(deploymentReader); - final Optional<String> globalServiceId = deploymentSpec.globalServiceId(); - if (globalServiceId.isPresent()) { - Set<ContainerCluster> containerClusters = model.getRoot().configModelRepo().getModels(ContainerModel.class).stream(). - map(ContainerModel::getCluster).filter(cc -> cc.getName().equals(globalServiceId.get())).collect(Collectors.toSet()); - if (containerClusters.size() != 1) { - throw new IllegalArgumentException("global-service-id '" + globalServiceId.get() + "' specified in deployment.xml does not match any container cluster id"); - } - } - } - } -} diff --git a/config-model/src/main/java/com/yahoo/vespa/model/application/validation/DeploymentSpecValidator.java b/config-model/src/main/java/com/yahoo/vespa/model/application/validation/DeploymentSpecValidator.java new file mode 100644 index 00000000000..ac38336a405 --- /dev/null +++ b/config-model/src/main/java/com/yahoo/vespa/model/application/validation/DeploymentSpecValidator.java @@ -0,0 +1,40 @@ +// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.model.application.validation; + +import com.yahoo.config.application.api.DeploymentInstanceSpec; +import com.yahoo.config.application.api.DeploymentSpec; +import com.yahoo.config.model.deploy.DeployState; +import com.yahoo.vespa.model.VespaModel; +import com.yahoo.vespa.model.container.ContainerModel; + +import java.io.Reader; +import java.util.List; +import java.util.Optional; + +/** + * Validates that deployment spec (deployment.xml) has valid values (for now + * only global-service-id is validated) + * + * @author hmusum + * @author bratseth + */ +public class DeploymentSpecValidator extends Validator { + + @Override + public void validate(VespaModel model, DeployState deployState) { + Optional<Reader> deployment = deployState.getApplicationPackage().getDeployment(); + if ( deployment.isEmpty()) return; + + Reader deploymentReader = deployment.get(); + DeploymentSpec deploymentSpec = DeploymentSpec.fromXml(deploymentReader); + List<ContainerModel> containers = model.getRoot().configModelRepo().getModels(ContainerModel.class); + for (DeploymentInstanceSpec instance : deploymentSpec.instances()) { + instance.globalServiceId().ifPresent(globalServiceId -> { + if ( containers.stream().noneMatch(container -> container.getCluster().getName().equals(globalServiceId))) + throw new IllegalArgumentException("The global-service-id in " + instance + ", '" + globalServiceId + + "' specified in deployment.xml does not match any container cluster id"); + }); + } + } + +} diff --git a/config-model/src/main/java/com/yahoo/vespa/model/application/validation/Validation.java b/config-model/src/main/java/com/yahoo/vespa/model/application/validation/Validation.java index 042c7cc867c..7d0d068f9d6 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/application/validation/Validation.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/application/validation/Validation.java @@ -53,7 +53,7 @@ public class Validation { new StreamingValidator().validate(model, deployState); new RankSetupValidator(validationParameters.ignoreValidationErrors()).validate(model, deployState); new NoPrefixForIndexes().validate(model, deployState); - new DeploymentFileValidator().validate(model, deployState); + new DeploymentSpecValidator().validate(model, deployState); new RankingConstantsValidator().validate(model, deployState); new SecretStoreValidator().validate(model, deployState); new TlsSecretsValidator().validate(model, deployState); diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java index f4c7f49a9a0..caf84d88cf4 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java @@ -197,7 +197,6 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { addClientProviders(deployState, spec, cluster); addServerProviders(deployState, spec, cluster); - addAthensCopperArgos(cluster, context); // Must be added after nodes. } @@ -228,14 +227,15 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { } private void addRotationProperties(ApplicationContainerCluster cluster, Zone zone, Set<Rotation> rotations, Set<ContainerEndpoint> endpoints, DeploymentSpec spec) { + Optional<String> globalServiceId = spec.requireInstance(app.getApplicationId().instance()).globalServiceId(); cluster.getContainers().forEach(container -> { - setRotations(container, rotations, endpoints, spec.globalServiceId(), cluster.getName()); + setRotations(container, rotations, endpoints, globalServiceId, cluster.getName()); container.setProp("activeRotation", Boolean.toString(zoneHasActiveRotation(zone, spec))); }); } private boolean zoneHasActiveRotation(Zone zone, DeploymentSpec spec) { - return spec.zones().stream() + return spec.requireInstance(app.getApplicationId().instance()).zones().stream() .anyMatch(declaredZone -> declaredZone.deploysTo(zone.environment(), Optional.of(zone.region())) && declaredZone.active()); } @@ -893,8 +893,8 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { Zone zone, DeploymentSpec spec) { spec.athenzDomain().ifPresent(domain -> { - AthenzService service = spec.athenzService(zone.environment(), zone.region()) - .orElseThrow(() -> new RuntimeException("Missing Athenz service configuration")); + AthenzService service = spec.requireInstance(app.getApplicationId().instance()).athenzService(zone.environment(), zone.region()) + .orElseThrow(() -> new RuntimeException("Missing Athenz service configuration in instance '" + app.getApplicationId().instance() + "'")); String zoneDnsSuffix = zone.environment().value() + "-" + zone.region().value() + "." + athenzDnsSuffix; IdentityProvider identityProvider = new IdentityProvider(domain, service, getLoadBalancerName(loadBalancerName, configServerSpecs), ztsUrl, zoneDnsSuffix, zone); cluster.addComponent(identityProvider); diff --git a/config-model/src/test/java/com/yahoo/vespa/model/application/validation/DeploymentFileValidatorTest.java b/config-model/src/test/java/com/yahoo/vespa/model/application/validation/DeploymentSpecValidatorTest.java index 5fc3f815b09..c6d56455d44 100644 --- a/config-model/src/test/java/com/yahoo/vespa/model/application/validation/DeploymentFileValidatorTest.java +++ b/config-model/src/test/java/com/yahoo/vespa/model/application/validation/DeploymentSpecValidatorTest.java @@ -18,7 +18,7 @@ import static org.junit.Assert.fail; /** * @author hmusum */ -public class DeploymentFileValidatorTest { +public class DeploymentSpecValidatorTest { @Test public void testDeploymentWithNonExistentGlobalId() throws IOException, SAXException { @@ -58,7 +58,7 @@ public class DeploymentFileValidatorTest { try { final DeployState deployState = builder.build(); VespaModel model = new VespaModel(new NullConfigModelRegistry(), deployState); - new DeploymentFileValidator().validate(model, deployState); + new DeploymentSpecValidator().validate(model, deployState); fail("Did not get expected exception"); } catch (IllegalArgumentException e) { assertThat(e.getMessage(), containsString("specified in deployment.xml does not match any container cluster id")); diff --git a/config-provisioning/src/main/java/com/yahoo/config/provision/Environment.java b/config-provisioning/src/main/java/com/yahoo/config/provision/Environment.java index b9573b21199..012f246a227 100644 --- a/config-provisioning/src/main/java/com/yahoo/config/provision/Environment.java +++ b/config-provisioning/src/main/java/com/yahoo/config/provision/Environment.java @@ -5,7 +5,6 @@ package com.yahoo.config.provision; * Environments in hosted Vespa. * * @author bratseth - * @since 5.11 */ public enum Environment { diff --git a/configserver/src/main/java/com/yahoo/vespa/config/server/session/SessionPreparer.java b/configserver/src/main/java/com/yahoo/vespa/config/server/session/SessionPreparer.java index 54c96c0461d..96df067843d 100644 --- a/configserver/src/main/java/com/yahoo/vespa/config/server/session/SessionPreparer.java +++ b/configserver/src/main/java/com/yahoo/vespa/config/server/session/SessionPreparer.java @@ -9,6 +9,7 @@ import com.yahoo.component.Version; import com.yahoo.component.Vtag; import com.yahoo.config.application.api.ApplicationPackage; import com.yahoo.config.application.api.DeployLogger; +import com.yahoo.config.application.api.DeploymentInstanceSpec; import com.yahoo.config.application.api.DeploymentSpec; import com.yahoo.config.application.api.FileRegistry; import com.yahoo.config.model.api.ConfigDefinitionRepo; @@ -121,7 +122,8 @@ public class SessionPreparer { preparation.writeTlsZK(); var globalServiceId = context.getApplicationPackage().getDeployment() .map(DeploymentSpec::fromXml) - .flatMap(DeploymentSpec::globalServiceId); + .map(spec -> spec.requireInstance(context.getApplicationPackage().getApplicationId().instance())) + .flatMap(DeploymentInstanceSpec::globalServiceId); preparation.writeContainerEndpointsZK(globalServiceId); preparation.distribute(); } diff --git a/configserver/src/test/java/com/yahoo/vespa/config/server/zookeeper/ZKApplicationPackageTest.java b/configserver/src/test/java/com/yahoo/vespa/config/server/zookeeper/ZKApplicationPackageTest.java index 8b8be1a27d7..f565111c363 100644 --- a/configserver/src/test/java/com/yahoo/vespa/config/server/zookeeper/ZKApplicationPackageTest.java +++ b/configserver/src/test/java/com/yahoo/vespa/config/server/zookeeper/ZKApplicationPackageTest.java @@ -81,7 +81,7 @@ public class ZKApplicationPackageTest { assertThat(readInfo.getHosts().iterator().next().flavor(), is(TEST_FLAVOR)); assertEquals("6.0.1", readInfo.getHosts().iterator().next().version().get().toString()); assertTrue(zkApp.getDeployment().isPresent()); - assertThat(DeploymentSpec.fromXml(zkApp.getDeployment().get()).globalServiceId().get(), is("mydisc")); + assertEquals("mydisc", DeploymentSpec.fromXml(zkApp.getDeployment().get()).instance("default").globalServiceId().get()); } private void feed(ConfigCurator zk, File dirToFeed) throws IOException { diff --git a/container-core/src/main/java/com/yahoo/container/handler/VipStatusHandler.java b/container-core/src/main/java/com/yahoo/container/handler/VipStatusHandler.java index a37255436ca..eceffb379aa 100644 --- a/container-core/src/main/java/com/yahoo/container/handler/VipStatusHandler.java +++ b/container-core/src/main/java/com/yahoo/container/handler/VipStatusHandler.java @@ -31,16 +31,12 @@ import com.yahoo.vespa.defaults.Defaults; */ public final class VipStatusHandler extends ThreadedHttpRequestHandler { - private static final Logger log = Logger.getLogger(VipStatusHandler.class.getName()); - private static final String NUM_REQUESTS_METRIC = "jdisc.http.requests.status"; private final boolean accessDisk; private final File statusFile; private final VipStatus vipStatus; - private volatile boolean previouslyInRotation = true; - // belongs in the response, but that's not a static class static final String OK_MESSAGE = "<title>OK</title>\n"; static final byte[] VIP_OK = Utf8.toBytes(OK_MESSAGE); @@ -162,6 +158,7 @@ public final class VipStatusHandler extends ThreadedHttpRequestHandler { * out of capacity. This is the default behavior. */ @Inject + @SuppressWarnings("unused") // injected public VipStatusHandler(VipStatusConfig vipConfig, Metric metric, VipStatus vipStatus) { // One thread should be enough for status handling - otherwise something else is completely wrong, // in which case this will eventually start returning a 503 (due to work rejection) as the bounded diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceSnapshot.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceSnapshot.java index a378bcb63bd..5ee6df9f034 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceSnapshot.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceSnapshot.java @@ -2,6 +2,7 @@ package com.yahoo.vespa.hosted.controller.api.integration.resource; import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.vespa.hosted.controller.api.integration.configserver.Node; import java.time.Instant; @@ -17,14 +18,16 @@ public class ResourceSnapshot { private final ApplicationId applicationId; private final ResourceAllocation resourceAllocation; private final Instant timestamp; + private final ZoneId zoneId; - public ResourceSnapshot(ApplicationId applicationId, double cpuCores, double memoryGb, double diskGb, Instant timestamp) { + public ResourceSnapshot(ApplicationId applicationId, double cpuCores, double memoryGb, double diskGb, Instant timestamp, ZoneId zoneId) { this.applicationId = applicationId; this.resourceAllocation = new ResourceAllocation(cpuCores, memoryGb, diskGb); this.timestamp = timestamp; + this.zoneId = zoneId; } - public static ResourceSnapshot from(List<Node> nodes, Instant timestamp) { + public static ResourceSnapshot from(List<Node> nodes, Instant timestamp, ZoneId zoneId) { Set<ApplicationId> applicationIds = nodes.stream() .filter(node -> node.owner().isPresent()) .map(node -> node.owner().get()) @@ -37,7 +40,8 @@ public class ResourceSnapshot { nodes.stream().mapToDouble(Node::vcpu).sum(), nodes.stream().mapToDouble(Node::memoryGb).sum(), nodes.stream().mapToDouble(Node::diskGb).sum(), - timestamp + timestamp, + zoneId ); } @@ -61,4 +65,8 @@ public class ResourceSnapshot { return timestamp; } + public ZoneId getZoneId() { + return zoneId; + } + } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java index 190a529f101..0cf0f59102e 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/ApplicationController.java @@ -385,10 +385,9 @@ public class ApplicationController { } if (zone.environment().isProduction()) // Assign and register endpoints - application = withRotation(application, instance); - - endpoints = registerEndpointsInDns(application.get().deploymentSpec(), application.get().require(instanceId.instance()), zone); + application = withRotation(applicationPackage.deploymentSpec(), application, instance); + endpoints = registerEndpointsInDns(applicationPackage.deploymentSpec(), application.get().require(instanceId.instance()), zone); if (controller.zoneRegistry().zones().directlyRouted().ids().contains(zone)) { // Provisions a new certificate if missing @@ -518,9 +517,9 @@ public class ApplicationController { } /** Makes sure the application has a global rotation, if eligible. */ - private LockedApplication withRotation(LockedApplication application, InstanceName instanceName) { + private LockedApplication withRotation(DeploymentSpec deploymentSpec, LockedApplication application, InstanceName instanceName) { try (RotationLock rotationLock = rotationRepository.lock()) { - var rotations = rotationRepository.getOrAssignRotations(application.get().deploymentSpec(), + var rotations = rotationRepository.getOrAssignRotations(deploymentSpec, application.get().require(instanceName), rotationLock); application = application.with(instanceName, instance -> instance.with(rotations)); @@ -536,7 +535,7 @@ public class ApplicationController { */ private Set<ContainerEndpoint> registerEndpointsInDns(DeploymentSpec deploymentSpec, Instance instance, ZoneId zone) { var containerEndpoints = new HashSet<ContainerEndpoint>(); - var registerLegacyNames = deploymentSpec.globalServiceId().isPresent(); + var registerLegacyNames = deploymentSpec.requireInstance(instance.name()).globalServiceId().isPresent(); for (var assignedRotation : instance.rotations()) { var names = new ArrayList<String>(); var endpoints = instance.endpointsIn(controller.system(), assignedRotation.endpointId()) @@ -628,8 +627,8 @@ public class ApplicationController { private LockedApplication withoutDeletedDeployments(LockedApplication application, InstanceName instance) { DeploymentSpec deploymentSpec = application.get().deploymentSpec(); List<Deployment> deploymentsToRemove = application.get().require(instance).productionDeployments().values().stream() - .filter(deployment -> ! deploymentSpec.includes(deployment.zone().environment(), - Optional.of(deployment.zone().region()))) + .filter(deployment -> ! deploymentSpec.requireInstance(instance).includes(deployment.zone().environment(), + Optional.of(deployment.zone().region()))) .collect(Collectors.toList()); if (deploymentsToRemove.isEmpty()) return application; @@ -653,7 +652,7 @@ public class ApplicationController { private Instance withoutUnreferencedDeploymentJobs(DeploymentSpec deploymentSpec, Instance instance) { for (JobType job : JobList.from(instance).production().mapToList(JobStatus::type)) { ZoneId zone = job.zone(controller.system()); - if (deploymentSpec.includes(zone.environment(), Optional.of(zone.region()))) + if (deploymentSpec.requireInstance(instance.name()).includes(zone.environment(), Optional.of(zone.region()))) continue; instance = instance.withoutDeploymentJob(job); } @@ -911,9 +910,9 @@ public class ApplicationController { * 2. If the principal is given, verify that the principal is tenant admin or admin of the tenant domain * 3. If the principal is not given, verify that the Athenz domain of the tenant equals Athenz domain given in deployment.xml * - * @param tenantName Tenant where application should be deployed - * @param applicationPackage Application package - * @param deployer Principal initiating the deployment, possibly empty + * @param tenantName tenant where application should be deployed + * @param applicationPackage application package + * @param deployer principal initiating the deployment, possibly empty */ public void verifyApplicationIdentityConfiguration(TenantName tenantName, ApplicationPackage applicationPackage, Optional<Principal> deployer) { verifyAllowedLaunchAthenzService(applicationPackage.deploymentSpec()); diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Instance.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Instance.java index f885b7a146e..627cde28fd0 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Instance.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Instance.java @@ -13,7 +13,6 @@ import com.yahoo.vespa.hosted.controller.api.integration.deployment.ApplicationV import com.yahoo.vespa.hosted.controller.api.integration.deployment.JobType; import com.yahoo.vespa.hosted.controller.application.AssignedRotation; import com.yahoo.vespa.hosted.controller.application.ClusterInfo; -import com.yahoo.vespa.hosted.controller.application.ClusterUtilization; import com.yahoo.vespa.hosted.controller.application.Deployment; import com.yahoo.vespa.hosted.controller.application.DeploymentJobs; import com.yahoo.vespa.hosted.controller.application.DeploymentMetrics; @@ -87,19 +86,12 @@ public class Instance { Deployment previousDeployment = deployments.getOrDefault(zone, new Deployment(zone, applicationVersion, version, instant)); Deployment newDeployment = new Deployment(zone, applicationVersion, version, instant, - previousDeployment.clusterUtils(), previousDeployment.clusterInfo(), previousDeployment.metrics().with(warnings), previousDeployment.activity()); return with(newDeployment); } - public Instance withClusterUtilization(ZoneId zone, Map<ClusterSpec.Id, ClusterUtilization> clusterUtilization) { - Deployment deployment = deployments.get(zone); - if (deployment == null) return this; // No longer deployed in this zone. - return with(deployment.withClusterUtils(clusterUtilization)); - } - public Instance withClusterInfo(ZoneId zone, Map<ClusterSpec.Id, ClusterInfo> clusterInfo) { Deployment deployment = deployments.get(zone); if (deployment == null) return this; // No longer deployed in this zone. diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Deployment.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Deployment.java index 03d084cd9e3..361dcf9dbf9 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Deployment.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/Deployment.java @@ -1,7 +1,6 @@ // Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.controller.application; -import com.google.common.collect.ImmutableMap; import com.yahoo.component.Version; import com.yahoo.config.provision.ClusterSpec.Id; import com.yahoo.vespa.hosted.controller.api.integration.deployment.ApplicationVersion; @@ -9,7 +8,6 @@ import com.yahoo.config.provision.zone.ZoneId; import java.time.Instant; import java.util.Collections; -import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -25,26 +23,24 @@ public class Deployment { private final ApplicationVersion applicationVersion; private final Version version; private final Instant deployTime; - private final Map<Id, ClusterUtilization> clusterUtilization; private final Map<Id, ClusterInfo> clusterInfo; private final DeploymentMetrics metrics; private final DeploymentActivity activity; public Deployment(ZoneId zone, ApplicationVersion applicationVersion, Version version, Instant deployTime) { - this(zone, applicationVersion, version, deployTime, Collections.emptyMap(), Collections.emptyMap(), + this(zone, applicationVersion, version, deployTime, Collections.emptyMap(), DeploymentMetrics.none, DeploymentActivity.none); } public Deployment(ZoneId zone, ApplicationVersion applicationVersion, Version version, Instant deployTime, - Map<Id, ClusterUtilization> clusterUtilization, Map<Id, ClusterInfo> clusterInfo, + Map<Id, ClusterInfo> clusterInfo, DeploymentMetrics metrics, DeploymentActivity activity) { this.zone = Objects.requireNonNull(zone, "zone cannot be null"); this.applicationVersion = Objects.requireNonNull(applicationVersion, "applicationVersion cannot be null"); this.version = Objects.requireNonNull(version, "version cannot be null"); this.deployTime = Objects.requireNonNull(deployTime, "deployTime cannot be null"); - this.clusterUtilization = ImmutableMap.copyOf(Objects.requireNonNull(clusterUtilization, "clusterUtilization cannot be null")); - this.clusterInfo = ImmutableMap.copyOf(Objects.requireNonNull(clusterInfo, "clusterInfo cannot be null")); + this.clusterInfo = Map.copyOf(Objects.requireNonNull(clusterInfo, "clusterInfo cannot be null")); this.metrics = Objects.requireNonNull(metrics, "deploymentMetrics cannot be null"); this.activity = Objects.requireNonNull(activity, "activity cannot be null"); } @@ -74,52 +70,26 @@ public class Deployment { return clusterInfo; } - /** Returns utilization of the clusters allocated to this */ - // TODO(mpolden): No longer updated. Remove this and associated serialization - public Map<Id, ClusterUtilization> clusterUtils() { - return clusterUtilization; - } - public Deployment recordActivityAt(Instant instant) { - return new Deployment(zone, applicationVersion, version, deployTime, clusterUtilization, clusterInfo, metrics, + return new Deployment(zone, applicationVersion, version, deployTime, clusterInfo, metrics, activity.recordAt(instant, metrics)); } - public Deployment withClusterUtils(Map<Id, ClusterUtilization> clusterUtilization) { - return new Deployment(zone, applicationVersion, version, deployTime, clusterUtilization, clusterInfo, metrics, + public Deployment withClusterUtils() { + return new Deployment(zone, applicationVersion, version, deployTime, clusterInfo, metrics, activity); } public Deployment withClusterInfo(Map<Id, ClusterInfo> newClusterInfo) { - return new Deployment(zone, applicationVersion, version, deployTime, clusterUtilization, newClusterInfo, metrics, + return new Deployment(zone, applicationVersion, version, deployTime, newClusterInfo, metrics, activity); } public Deployment withMetrics(DeploymentMetrics metrics) { - return new Deployment(zone, applicationVersion, version, deployTime, clusterUtilization, clusterInfo, metrics, + return new Deployment(zone, applicationVersion, version, deployTime, clusterInfo, metrics, activity); } - /** - * Calculate cost for this deployment. - * - * This is based on cluster utilization and cluster info. - */ - public DeploymentCost calculateCost() { - - Map<String, ClusterCost> costClusters = new HashMap<>(); - for (Id clusterId : clusterUtilization.keySet()) { - - // Only include cluster cost if we have both cluster utilization and cluster info - if (clusterInfo.containsKey(clusterId)) { - costClusters.put(clusterId.value(), new ClusterCost(clusterInfo.get(clusterId), - clusterUtilization.get(clusterId))); - } - } - - return new DeploymentCost(costClusters); - } - @Override public String toString() { return "deployment to " + zone + " of " + applicationVersion + " on version " + version + " at " + deployTime; diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentCost.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentCost.java index 371e1c41e32..393c14b35d3 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentCost.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentCost.java @@ -17,7 +17,7 @@ public class DeploymentCost { private final Map<String, ClusterCost> clusters; - DeploymentCost(Map<String, ClusterCost> clusterCosts) { + public DeploymentCost(Map<String, ClusterCost> clusterCosts) { clusters = new HashMap<>(clusterCosts); double tco = 0; diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentSpecValidator.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentSpecValidator.java index ce7904dc829..5c4d5874e53 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentSpecValidator.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/application/DeploymentSpecValidator.java @@ -39,7 +39,7 @@ public class DeploymentSpecValidator { /** Verify that each of the production zones listed in the deployment spec exist in this system */ private void validateSteps(DeploymentSpec deploymentSpec) { new DeploymentSteps(deploymentSpec, controller::system).jobs(); - deploymentSpec.zones().stream() + deploymentSpec.instances().stream().flatMap(instance -> instance.zones().stream()) .filter(zone -> zone.environment() == Environment.prod) .forEach(zone -> { if ( ! controller.zoneRegistry().hasZone(ZoneId.from(zone.environment(), @@ -51,16 +51,19 @@ public class DeploymentSpecValidator { /** Verify that no single endpoint contains regions in different clouds */ private void validateEndpoints(DeploymentSpec deploymentSpec) { - for (var endpoint : deploymentSpec.endpoints()) { - var clouds = new HashSet<CloudName>(); - for (var region : endpoint.regions()) { - for (ZoneApi zone : controller.zoneRegistry().zones().all().in(region).zones()) { - clouds.add(zone.getCloudName()); + for (var instance : deploymentSpec.instances()) { + for (var endpoint : instance.endpoints()) { + var clouds = new HashSet<CloudName>(); + for (var region : endpoint.regions()) { + for (ZoneApi zone : controller.zoneRegistry().zones().all().in(region).zones()) { + clouds.add(zone.getCloudName()); + } + } + if (clouds.size() != 1) { + throw new IllegalArgumentException("Endpoint '" + endpoint.endpointId() + "' in " + instance + + " cannot contain regions in different clouds: " + + endpoint.regions().stream().sorted().collect(Collectors.toList())); } - } - if (clouds.size() != 1) { - throw new IllegalArgumentException("Endpoint '" + endpoint.endpointId() + "' cannot contain regions in different clouds: " + - endpoint.regions().stream().sorted().collect(Collectors.toList())); } } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTrigger.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTrigger.java index 376048143d9..3df889d7a88 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTrigger.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/deployment/DeploymentTrigger.java @@ -372,9 +372,8 @@ public class DeploymentTrigger { } else { // All jobs are complete; find the time of completion of this step. if (stepJobs.isEmpty()) { // No jobs means this is a delay step. - Duration delay = ((DeploymentSpec.Delay) step).duration(); - completedAt = completedAt.map(at -> at.plus(delay)).filter(at -> !at.isAfter(clock.instant())); - reason += " after a delay of " + delay; + completedAt = completedAt.map(at -> at.plus(step.delay())).filter(at -> !at.isAfter(clock.instant())); + reason += " after a delay of " + step.delay(); } else { completedAt = stepJobs.stream().map(job -> instance.deploymentJobs().statusOf(job).get().lastCompleted().get().at()).max(naturalOrder()); diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ResourceMeterMaintainer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ResourceMeterMaintainer.java index c700ddac51c..0e14b61c5c5 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ResourceMeterMaintainer.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/maintenance/ResourceMeterMaintainer.java @@ -3,6 +3,8 @@ package com.yahoo.vespa.hosted.controller.maintenance; import com.yahoo.config.provision.CloudName; import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.zone.ZoneApi; +import com.yahoo.config.provision.zone.ZoneId; import com.yahoo.jdisc.Metric; import com.yahoo.vespa.hosted.controller.Controller; import com.yahoo.vespa.hosted.controller.api.integration.configserver.Node; @@ -15,6 +17,7 @@ import java.time.Duration; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.function.Predicate; import java.util.stream.Collectors; /** @@ -47,33 +50,44 @@ public class ResourceMeterMaintainer extends Maintainer { @Override protected void maintain() { - Collection<ResourceSnapshot> resourceSnapshots = getResourceSnapshots(allocatedNodes()); + + Collection<ResourceSnapshot> resourceSnapshots = getAllResourceSnapshots(); meteringClient.consume(resourceSnapshots); metric.set(METERING_LAST_REPORTED, clock.millis() / 1000, metric.createContext(Collections.emptyMap())); // total metered resource usage, for alerting on drastic changes metric.set(METERING_TOTAL_REPORTED, - resourceSnapshots.stream().mapToDouble(r -> r.getCpuCores() + r.getMemoryGb() + r.getDiskGb()).sum(), + resourceSnapshots.stream() + .mapToDouble(r -> r.getCpuCores() + r.getMemoryGb() + r.getDiskGb()).sum(), metric.createContext(Collections.emptyMap())); } - private List<Node> allocatedNodes() { + private Collection<ResourceSnapshot> getAllResourceSnapshots() { return controller().zoneRegistry().zones() .ofCloud(CloudName.from("aws")) .reachable().zones().stream() - .flatMap(zone -> nodeRepository.list(zone.getId()).stream()) - .filter(node -> node.owner().isPresent()) - .filter(node -> ! node.owner().get().tenant().value().equals("hosted-vespa")) + .map(ZoneApi::getId) + .map(zoneId -> createResourceSnapshotsFromNodes(zoneId, nodeRepository.list(zoneId))) + .flatMap(Collection::stream) .collect(Collectors.toList()); } - private Collection<ResourceSnapshot> getResourceSnapshots(List<Node> nodes) { + private Collection<ResourceSnapshot> createResourceSnapshotsFromNodes(ZoneId zoneId, List<Node> nodes) { return nodes.stream() - .collect(Collectors.groupingBy(node -> node.owner().get(), - Collectors.collectingAndThen(Collectors.toList(), - nodeList -> ResourceSnapshot.from(nodeList, - clock.instant())) - )).values(); + .filter(unlessNodeOwnerIsHostedVespa()) + .collect(Collectors.groupingBy(node -> + node.owner().get(), + Collectors.collectingAndThen(Collectors.toList(), + nodeList -> ResourceSnapshot.from( + nodeList, + clock.instant(), + zoneId)) + )).values(); } + private Predicate<Node> unlessNodeOwnerIsHostedVespa() { + return node -> node.owner().map(owner -> + !owner.tenant().value().equals("hosted-vespa") + ).orElse(false); + } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializer.java index 61fd0b67ec9..e67d5aea45d 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializer.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializer.java @@ -23,7 +23,6 @@ import com.yahoo.vespa.hosted.controller.api.integration.organization.User; import com.yahoo.vespa.hosted.controller.application.AssignedRotation; import com.yahoo.vespa.hosted.controller.application.Change; import com.yahoo.vespa.hosted.controller.application.ClusterInfo; -import com.yahoo.vespa.hosted.controller.application.ClusterUtilization; import com.yahoo.vespa.hosted.controller.application.Deployment; import com.yahoo.vespa.hosted.controller.application.DeploymentActivity; import com.yahoo.vespa.hosted.controller.application.DeploymentJobs; @@ -88,7 +87,6 @@ public class ApplicationSerializer { private static final String pemDeployKeysField = "pemDeployKeys"; private static final String assignedRotationClusterField = "clusterId"; private static final String assignedRotationRotationField = "rotationId"; - private static final String applicationCertificateField = "applicationCertificate"; // Instance fields private static final String instanceNameField = "instanceName"; @@ -147,13 +145,6 @@ public class ApplicationSerializer { private static final String clusterInfoTypeField = "clusterType"; private static final String clusterInfoHostnamesField = "hostnames"; - // ClusterUtils fields - private static final String clusterUtilsField = "clusterUtils"; - private static final String clusterUtilsCpuField = "cpu"; - private static final String clusterUtilsMemField = "mem"; - private static final String clusterUtilsDiskField = "disk"; - private static final String clusterUtilsDiskBusyField = "diskbusy"; - // Deployment metrics fields private static final String deploymentMetricsField = "metrics"; private static final String deploymentMetricsQPSField = "queriesPerSecond"; @@ -220,7 +211,6 @@ public class ApplicationSerializer { object.setLong(deployTimeField, deployment.at().toEpochMilli()); toSlime(deployment.applicationVersion(), object.setObject(applicationPackageRevisionField)); clusterInfoToSlime(deployment.clusterInfo(), object); - clusterUtilsToSlime(deployment.clusterUtils(), object); deploymentMetricsToSlime(deployment.metrics(), object); deployment.activity().lastQueried().ifPresent(instant -> object.setLong(lastQueriedField, instant.toEpochMilli())); deployment.activity().lastWritten().ifPresent(instant -> object.setLong(lastWrittenField, instant.toEpochMilli())); @@ -262,20 +252,6 @@ public class ApplicationSerializer { } } - private void clusterUtilsToSlime(Map<ClusterSpec.Id, ClusterUtilization> clusters, Cursor object) { - Cursor root = object.setObject(clusterUtilsField); - for (Map.Entry<ClusterSpec.Id, ClusterUtilization> entry : clusters.entrySet()) { - toSlime(entry.getValue(), root.setObject(entry.getKey().value())); - } - } - - private void toSlime(ClusterUtilization utils, Cursor object) { - object.setDouble(clusterUtilsCpuField, utils.getCpu()); - object.setDouble(clusterUtilsMemField, utils.getMemory()); - object.setDouble(clusterUtilsDiskField, utils.getDisk()); - object.setDouble(clusterUtilsDiskBusyField, utils.getDiskBusy()); - } - private void zoneIdToSlime(ZoneId zone, Cursor object) { object.setString(environmentField, zone.environment().value()); object.setString(regionField, zone.region().value()); @@ -425,7 +401,6 @@ public class ApplicationSerializer { applicationVersionFromSlime(deploymentObject.field(applicationPackageRevisionField)), Version.fromString(deploymentObject.field(versionField).asString()), Instant.ofEpochMilli(deploymentObject.field(deployTimeField).asLong()), - Map.of(), clusterInfoMapFromSlime(deploymentObject.field(clusterInfoField)), deploymentMetricsFromSlime(deploymentObject.field(deploymentMetricsField)), DeploymentActivity.create(Serializers.optionalInstant(deploymentObject.field(lastQueriedField)), diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java index 74010c4e41d..c37309b87ad 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java @@ -380,9 +380,13 @@ public class ApplicationApiHandler extends LoggingRequestHandler { Principal user = request.getJDiscRequest().getUserPrincipal(); String pemDeveloperKey = toSlime(request.getData()).get().field("key").asString(); PublicKey developerKey = KeyUtils.fromPemEncodedPublicKey(pemDeveloperKey); - controller.tenants().lockOrThrow(TenantName.from(tenantName), LockedTenant.Cloud.class, tenant -> - controller.tenants().store(tenant.withDeveloperKey(developerKey, user))); - return new MessageResponse("Set developer key " + pemDeveloperKey + " for " + user); + Slime root = new Slime(); + controller.tenants().lockOrThrow(TenantName.from(tenantName), LockedTenant.Cloud.class, tenant -> { + tenant = tenant.withDeveloperKey(developerKey, user); + toSlime(root.setObject().setArray("keys"), tenant.get().developerKeys()); + controller.tenants().store(tenant); + }); + return new SlimeJsonResponse(root); } private HttpResponse removeDeveloperKey(String tenantName, HttpRequest request) { @@ -392,27 +396,49 @@ public class ApplicationApiHandler extends LoggingRequestHandler { String pemDeveloperKey = toSlime(request.getData()).get().field("key").asString(); PublicKey developerKey = KeyUtils.fromPemEncodedPublicKey(pemDeveloperKey); Principal user = ((CloudTenant) controller.tenants().require(TenantName.from(tenantName))).developerKeys().get(developerKey); - controller.tenants().lockOrThrow(TenantName.from(tenantName), LockedTenant.Cloud.class, tenant -> - controller.tenants().store(tenant.withoutDeveloperKey(developerKey))); - return new MessageResponse("Removed developer key " + pemDeveloperKey + " for " + user); + Slime root = new Slime(); + controller.tenants().lockOrThrow(TenantName.from(tenantName), LockedTenant.Cloud.class, tenant -> { + tenant = tenant.withoutDeveloperKey(developerKey); + toSlime(root.setObject().setArray("keys"), tenant.get().developerKeys()); + controller.tenants().store(tenant); + }); + return new SlimeJsonResponse(root); + } + + private void toSlime(Cursor keysArray, Map<PublicKey, Principal> keys) { + keys.forEach((key, principal) -> { + Cursor keyObject = keysArray.addObject(); + keyObject.setString("key", KeyUtils.toPem(key)); + keyObject.setString("user", principal.getName()); + }); } private HttpResponse addDeployKey(String tenantName, String applicationName, HttpRequest request) { String pemDeployKey = toSlime(request.getData()).get().field("key").asString(); PublicKey deployKey = KeyUtils.fromPemEncodedPublicKey(pemDeployKey); - controller.applications().lockApplicationOrThrow(TenantAndApplicationId.from(tenantName, applicationName), application -> - controller.applications().store(application.withDeployKey(deployKey))); - - return new MessageResponse("Added deploy key " + pemDeployKey); + Slime root = new Slime(); + controller.applications().lockApplicationOrThrow(TenantAndApplicationId.from(tenantName, applicationName), application -> { + application = application.withDeployKey(deployKey); + application.get().deployKeys().stream() + .map(KeyUtils::toPem) + .forEach(root.setObject().setArray("keys")::addString); + controller.applications().store(application); + }); + return new SlimeJsonResponse(root); } private HttpResponse removeDeployKey(String tenantName, String applicationName, HttpRequest request) { String pemDeployKey = toSlime(request.getData()).get().field("key").asString(); PublicKey deployKey = KeyUtils.fromPemEncodedPublicKey(pemDeployKey); - controller.applications().lockApplicationOrThrow(TenantAndApplicationId.from(tenantName, applicationName), application -> - controller.applications().store(application.withoutDeployKey(deployKey))); - - return new MessageResponse("Removed deploy key " + pemDeployKey); + Slime root = new Slime(); + controller.applications().lockApplicationOrThrow(TenantAndApplicationId.from(tenantName, applicationName), application -> { + application = application.withoutDeployKey(deployKey); + application.get().deployKeys().stream() + .map(KeyUtils::toPem) + .forEach(root.setObject().setArray("keys")::addString); + controller.applications().store(application); + }); + return new SlimeJsonResponse(root); } private HttpResponse patchApplication(String tenantName, String applicationName, HttpRequest request) { @@ -751,7 +777,7 @@ public class ApplicationApiHandler extends LoggingRequestHandler { deployment.activity().lastWritesPerSecond().ifPresent(value -> activity.setDouble("lastWritesPerSecond", value)); // Cost - DeploymentCost appCost = deployment.calculateCost(); + DeploymentCost appCost = new DeploymentCost(Map.of()); Cursor costObject = response.setObject("cost"); toSlime(appCost, costObject); diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/rotation/RotationRepository.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/rotation/RotationRepository.java index a16ca5cb201..9f6bbcd2a5a 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/rotation/RotationRepository.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/rotation/RotationRepository.java @@ -77,22 +77,25 @@ public class RotationRepository { * If a rotation is already assigned to the application, that rotation will be returned. * If no rotation is assigned, return an available rotation. The caller is responsible for assigning the rotation. * - * @param deploymentSpec The deployment spec for the application - * @param instance The instance requesting a rotation - * @param lock Lock which must be acquired by the caller + * @param deploymentSpec the deployment spec for the application + * @param instance the instance requesting a rotation + * @param lock lock which must be acquired by the caller */ public Rotation getOrAssignRotation(DeploymentSpec deploymentSpec, Instance instance, RotationLock lock) { if ( ! instance.rotations().isEmpty()) { return allRotations.get(instance.rotations().get(0).rotationId()); } - if (deploymentSpec.globalServiceId().isEmpty()) { - throw new IllegalArgumentException("global-service-id is not set in deployment spec"); + + if (deploymentSpec.requireInstance(instance.name()).globalServiceId().isEmpty()) { + throw new IllegalArgumentException("global-service-id is not set in deployment spec for instance '" + + instance.name() + "'"); } - long productionZones = deploymentSpec.zones().stream() - .filter(zone -> zone.deploysTo(Environment.prod)) - .count(); + long productionZones = deploymentSpec.requireInstance(instance.name()).zones().stream() + .filter(zone -> zone.deploysTo(Environment.prod)) + .count(); if (productionZones < 2) { - throw new IllegalArgumentException("global-service-id is set but less than 2 prod zones are defined"); + throw new IllegalArgumentException("global-service-id is set but less than 2 prod zones are defined " + + "in instance '" + instance.name() + "'"); } return findAvailableRotation(instance.id(), lock); } @@ -110,22 +113,23 @@ public class RotationRepository { * @return List of rotation assignments - either new or existing */ public List<AssignedRotation> getOrAssignRotations(DeploymentSpec deploymentSpec, Instance instance, RotationLock lock) { - if (deploymentSpec.globalServiceId().isPresent() && ! deploymentSpec.endpoints().isEmpty()) { + if (deploymentSpec.requireInstance(instance.name()).globalServiceId().isPresent() + && ! deploymentSpec.requireInstance(instance.name()).endpoints().isEmpty()) { throw new IllegalArgumentException("Cannot provision rotations with both global-service-id and 'endpoints'"); } // Support the older case of setting global-service-id - if (deploymentSpec.globalServiceId().isPresent()) { - final var regions = deploymentSpec.zones().stream() - .filter(zone -> zone.environment().isProduction()) - .flatMap(zone -> zone.region().stream()) - .collect(Collectors.toSet()); + if (deploymentSpec.requireInstance(instance.name()).globalServiceId().isPresent()) { + var regions = deploymentSpec.requireInstance(instance.name()).zones().stream() + .filter(zone -> zone.environment().isProduction()) + .flatMap(zone -> zone.region().stream()) + .collect(Collectors.toSet()); - final var rotation = getOrAssignRotation(deploymentSpec, instance, lock); + var rotation = getOrAssignRotation(deploymentSpec, instance, lock); return List.of( new AssignedRotation( - new ClusterSpec.Id(deploymentSpec.globalServiceId().get()), + new ClusterSpec.Id(deploymentSpec.requireInstance(instance.name()).globalServiceId().get()), EndpointId.default_(), rotation.id(), regions @@ -133,8 +137,8 @@ public class RotationRepository { ); } - final Map<EndpointId, AssignedRotation> existingAssignments = existingEndpointAssignments(deploymentSpec, instance); - final Map<EndpointId, AssignedRotation> updatedAssignments = assignRotationsToEndpoints(deploymentSpec, existingAssignments, lock); + Map<EndpointId, AssignedRotation> existingAssignments = existingEndpointAssignments(deploymentSpec, instance); + Map<EndpointId, AssignedRotation> updatedAssignments = assignRotationsToEndpoints(deploymentSpec, existingAssignments, lock); existingAssignments.putAll(updatedAssignments); @@ -142,11 +146,11 @@ public class RotationRepository { } private Map<EndpointId, AssignedRotation> assignRotationsToEndpoints(DeploymentSpec deploymentSpec, Map<EndpointId, AssignedRotation> existingAssignments, RotationLock lock) { - final var availableRotations = new ArrayList<>(availableRotations(lock).values()); + var availableRotations = new ArrayList<>(availableRotations(lock).values()); - final var neededRotations = deploymentSpec.endpoints().stream() - .filter(Predicate.not(endpoint -> existingAssignments.containsKey(EndpointId.of(endpoint.endpointId())))) - .collect(Collectors.toSet()); + var neededRotations = deploymentSpec.endpoints().stream() + .filter(Predicate.not(endpoint -> existingAssignments.containsKey(EndpointId.of(endpoint.endpointId())))) + .collect(Collectors.toSet()); if (neededRotations.size() > availableRotations.size()) { throw new IllegalStateException("Hosted Vespa ran out of rotations, unable to assign rotation: need " + neededRotations.size() + ", have " + availableRotations.size()); @@ -172,34 +176,26 @@ public class RotationRepository { } private Map<EndpointId, AssignedRotation> existingEndpointAssignments(DeploymentSpec deploymentSpec, Instance instance) { - // // Get the regions that has been configured for an endpoint. Empty set if the endpoint // is no longer mentioned in the configuration file. - // - final Function<EndpointId, Set<RegionName>> configuredRegionsForEndpoint = endpointId -> { - return deploymentSpec.endpoints().stream() + Function<EndpointId, Set<RegionName>> configuredRegionsForEndpoint = endpointId -> + deploymentSpec.requireInstance(instance.name()).endpoints().stream() .filter(endpoint -> endpointId.id().equals(endpoint.endpointId())) .map(Endpoint::regions) .findFirst() .orElse(Set.of()); - }; - // // Build a new AssignedRotation instance where we update set of regions from the configuration instead - // of using the one already mentioned in the assignment. This allows us to overwrite the set of regions - // when - final Function<AssignedRotation, AssignedRotation> assignedRotationWithConfiguredRegions = assignedRotation -> { - return new AssignedRotation( + // of using the one already mentioned in the assignment. This allows us to overwrite the set of regions. + Function<AssignedRotation, AssignedRotation> assignedRotationWithConfiguredRegions = assignedRotation -> + new AssignedRotation( assignedRotation.clusterId(), assignedRotation.endpointId(), assignedRotation.rotationId(), - configuredRegionsForEndpoint.apply(assignedRotation.endpointId()) - ); - }; + configuredRegionsForEndpoint.apply(assignedRotation.endpointId())); return instance.rotations().stream() - .collect( - Collectors.toMap( + .collect(Collectors.toMap( AssignedRotation::endpointId, assignedRotationWithConfiguredRegions, (a, b) -> { diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTest.java index ebf80eb9daa..e3682a78b7d 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ControllerTest.java @@ -72,7 +72,6 @@ public class ControllerTest { @Test public void testDeployment() { // Setup system - ApplicationController applications = tester.controller().applications(); ApplicationPackage applicationPackage = new ApplicationPackageBuilder() .environment(Environment.prod) .region("us-west-1") @@ -753,7 +752,7 @@ public class ControllerTest { tester.deployCompletely(application, applicationPackage); fail("Expected exception"); } catch (IllegalArgumentException e) { - assertEquals("Endpoint 'default' cannot contain regions in different clouds: [aws-us-east-1, us-west-1]", e.getMessage()); + assertEquals("Endpoint 'default' in instance 'default' cannot contain regions in different clouds: [aws-us-east-1, us-west-1]", e.getMessage()); } var applicationPackage2 = new ApplicationPackageBuilder() @@ -766,7 +765,7 @@ public class ControllerTest { tester.deployCompletely(application, applicationPackage2); fail("Expected exception"); } catch (IllegalArgumentException e) { - assertEquals("Endpoint 'foo' cannot contain regions in different clouds: [aws-us-east-1, us-west-1]", e.getMessage()); + assertEquals("Endpoint 'foo' in instance 'default' cannot contain regions in different clouds: [aws-us-east-1, us-west-1]", e.getMessage()); } } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/ApplicationPackageBuilder.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/ApplicationPackageBuilder.java index 25e562ed046..9449f2b0854 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/ApplicationPackageBuilder.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/deployment/ApplicationPackageBuilder.java @@ -47,6 +47,7 @@ public class ApplicationPackageBuilder { private final List<X509Certificate> trustedCertificates = new ArrayList<>(); private OptionalInt majorVersion = OptionalInt.empty(); + private String instances = "default"; private String upgradePolicy = null; private Environment environment = Environment.prod; private String globalServiceId = null; @@ -58,6 +59,11 @@ public class ApplicationPackageBuilder { return this; } + public ApplicationPackageBuilder instances(String instances) { + this.instances = instances; + return this; + } + public ApplicationPackageBuilder upgradePolicy(String upgradePolicy) { this.upgradePolicy = upgradePolicy; return this; @@ -90,7 +96,7 @@ public class ApplicationPackageBuilder { } public ApplicationPackageBuilder region(String regionName) { - environmentBody.append(" <region active='true'>"); + environmentBody.append(" <region active='true'>"); environmentBody.append(regionName); environmentBody.append("</region>\n"); return this; @@ -112,7 +118,7 @@ public class ApplicationPackageBuilder { public ApplicationPackageBuilder blockChange(boolean revision, boolean version, String daySpec, String hourSpec, String zoneSpec) { - blockChange.append(" <block-change"); + blockChange.append(" <block-change"); blockChange.append(" revision='").append(revision).append("'"); blockChange.append(" version='").append(version).append("'"); blockChange.append(" days='").append(daySpec).append("'"); @@ -166,14 +172,15 @@ public class ApplicationPackageBuilder { xml.append(athenzIdentityAttributes); } xml.append(">\n"); + xml.append(" <instance id='").append(instances).append("'>\n"); if (upgradePolicy != null) { - xml.append("<upgrade policy='"); + xml.append(" <upgrade policy='"); xml.append(upgradePolicy); xml.append("'/>\n"); } xml.append(notifications); xml.append(blockChange); - xml.append(" <"); + xml.append(" <"); xml.append(environment.value()); if (globalServiceId != null) { xml.append(" global-service-id='"); @@ -182,13 +189,14 @@ public class ApplicationPackageBuilder { } xml.append(">\n"); xml.append(environmentBody); - xml.append(" </"); + xml.append(" </"); xml.append(environment.value()); xml.append(">\n"); - xml.append(" <endpoints>\n"); + xml.append(" <endpoints>\n"); xml.append(endpointsBody); - xml.append(" </endpoints>\n"); - xml.append("</deployment>"); + xml.append(" </endpoints>\n"); + xml.append(" </instance>\n"); + xml.append("</deployment>\n"); return xml.toString().getBytes(UTF_8); } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/ResourceMeterMaintainerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/ResourceMeterMaintainerTest.java index f28ce83e643..0245e7475f7 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/ResourceMeterMaintainerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/ResourceMeterMaintainerTest.java @@ -11,7 +11,6 @@ import org.junit.Test; import java.time.Duration; import java.util.Collection; -import java.util.List; import static org.junit.Assert.assertEquals; @@ -27,20 +26,14 @@ public class ResourceMeterMaintainerTest { @Test public void testMaintainer() { - var awsZone = ZoneApiMock.newBuilder().withId("prod.aws-us-east-1").withCloud("aws").build(); - tester.zoneRegistry().setZones( - ZoneApiMock.newBuilder().withId("prod.us-east-3").build(), - ZoneApiMock.newBuilder().withId("prod.us-west-1").build(), - ZoneApiMock.newBuilder().withId("prod.us-central-1").build(), - awsZone); - tester.configServer().nodeRepository().addFixedNodes(awsZone.getId()); + setUpZones(); ResourceMeterMaintainer resourceMeterMaintainer = new ResourceMeterMaintainer(tester.controller(), Duration.ofMinutes(5), new JobControl(tester.curator()), metrics, snapshotConsumer); resourceMeterMaintainer.maintain(); Collection<ResourceSnapshot> consumedResources = snapshotConsumer.consumedResources(); // The mocked repository contains two applications, so we should also consume two ResourceSnapshots - assertEquals(2, consumedResources.size()); + assertEquals(4, consumedResources.size()); ResourceSnapshot app1 = consumedResources.stream().filter(snapshot -> snapshot.getApplicationId().equals(ApplicationId.from("tenant1", "app1", "default"))).findFirst().orElseThrow(); ResourceSnapshot app2 = consumedResources.stream().filter(snapshot -> snapshot.getApplicationId().equals(ApplicationId.from("tenant2", "app2", "default"))).findFirst().orElseThrow(); @@ -53,7 +46,19 @@ public class ResourceMeterMaintainerTest { assertEquals(500, app2.getDiskGb(), DELTA); assertEquals(tester.clock().millis()/1000, metrics.getMetric("metering_last_reported")); - assertEquals(1112.0d, (Double) metrics.getMetric("metering_total_reported"), DELTA); + assertEquals(2224.0d, (Double) metrics.getMetric("metering_total_reported"), DELTA); } + private void setUpZones() { + ZoneApiMock nonAwsZone = ZoneApiMock.newBuilder().withId("test.region-1").build(); + ZoneApiMock awsZone1 = ZoneApiMock.newBuilder().withId("prod.region-2").withCloud("aws").build(); + ZoneApiMock awsZone2 = ZoneApiMock.newBuilder().withId("test.region-3").withCloud("aws").build(); + tester.zoneRegistry().setZones( + nonAwsZone, + awsZone1, + awsZone2); + tester.configServer().nodeRepository().addFixedNodes(nonAwsZone.getId()); + tester.configServer().nodeRepository().addFixedNodes(awsZone1.getId()); + tester.configServer().nodeRepository().addFixedNodes(awsZone2.getId()); + } } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializerTest.java index 08963b9fec7..447bce0a544 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/ApplicationSerializerTest.java @@ -19,7 +19,6 @@ import com.yahoo.vespa.hosted.controller.api.integration.organization.User; import com.yahoo.vespa.hosted.controller.application.AssignedRotation; import com.yahoo.vespa.hosted.controller.application.Change; import com.yahoo.vespa.hosted.controller.application.ClusterInfo; -import com.yahoo.vespa.hosted.controller.application.ClusterUtilization; import com.yahoo.vespa.hosted.controller.application.Deployment; import com.yahoo.vespa.hosted.controller.application.DeploymentActivity; import com.yahoo.vespa.hosted.controller.application.DeploymentJobs; @@ -92,7 +91,7 @@ public class ApplicationSerializerTest { Instant activityAt = Instant.parse("2018-06-01T10:15:30.00Z"); deployments.add(new Deployment(zone1, applicationVersion1, Version.fromString("1.2.3"), Instant.ofEpochMilli(3))); // One deployment without cluster info and utils deployments.add(new Deployment(zone2, applicationVersion2, Version.fromString("1.2.3"), Instant.ofEpochMilli(5), - createClusterUtils(3, 0.2), createClusterInfo(3, 4), + createClusterInfo(3, 4), new DeploymentMetrics(2, 3, 4, 5, 6, Optional.of(Instant.now().truncatedTo(ChronoUnit.MILLIS)), Map.of(DeploymentMetrics.Warning.all, 3)), @@ -191,10 +190,6 @@ public class ApplicationSerializerTest { assertEquals(original.require(id1.instance()).rotations(), serialized.require(id1.instance()).rotations()); assertEquals(original.require(id1.instance()).rotationStatus(), serialized.require(id1.instance()).rotationStatus()); - // Test cluster utilization - assertEquals(0, serialized.require(id1.instance()).deployments().get(zone1).clusterUtils().size()); - assertEquals(0, serialized.require(id1.instance()).deployments().get(zone2).clusterUtils().size()); - // Test cluster info assertEquals(3, serialized.require(id1.instance()).deployments().get(zone2).clusterInfo().size()); assertEquals(10, serialized.require(id1.instance()).deployments().get(zone2).clusterInfo().get(ClusterSpec.Id.from("id2")).getFlavorCost()); @@ -232,21 +227,6 @@ public class ApplicationSerializerTest { return result; } - private Map<ClusterSpec.Id, ClusterUtilization> createClusterUtils(int clusters, double inc) { - Map<ClusterSpec.Id, ClusterUtilization> result = new HashMap<>(); - - ClusterUtilization util = new ClusterUtilization(0,0,0,0); - for (int cluster = 0; cluster < clusters; cluster++) { - double agg = cluster*inc; - result.put(ClusterSpec.Id.from("id" + cluster), new ClusterUtilization( - util.getMemory()+ agg, - util.getCpu()+ agg, - util.getDisk() + agg, - util.getDiskBusy() + agg)); - } - return result; - } - @Test public void testCompleteApplicationDeserialization() throws Exception { byte[] applicationJson = Files.readAllBytes(testData.resolve("complete-application.json")); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java index 7cacd91a5c4..9c957785606 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiTest.java @@ -45,7 +45,6 @@ import com.yahoo.vespa.hosted.controller.api.integration.stubs.MockMeteringClien import com.yahoo.vespa.hosted.controller.application.ApplicationPackage; import com.yahoo.vespa.hosted.controller.application.Change; import com.yahoo.vespa.hosted.controller.application.ClusterInfo; -import com.yahoo.vespa.hosted.controller.application.ClusterUtilization; import com.yahoo.vespa.hosted.controller.application.Deployment; import com.yahoo.vespa.hosted.controller.application.DeploymentJobs; import com.yahoo.vespa.hosted.controller.application.DeploymentMetrics; @@ -65,8 +64,6 @@ import com.yahoo.vespa.hosted.controller.metric.ApplicationMetrics; import com.yahoo.vespa.hosted.controller.restapi.ContainerControllerTester; import com.yahoo.vespa.hosted.controller.restapi.ContainerTester; import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerTest; -import com.yahoo.vespa.hosted.controller.rotation.RotationState; -import com.yahoo.vespa.hosted.controller.rotation.RotationStatus; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.versions.VespaVersion; import com.yahoo.yolean.Exceptions; @@ -84,7 +81,6 @@ import java.util.ArrayList; import java.util.Base64; import java.util.Collections; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -117,7 +113,18 @@ public class ApplicationApiTest extends ControllerContainerTest { "-----END PUBLIC KEY-----\n"; private static final String quotedPemPublicKey = pemPublicKey.replaceAll("\\n", "\\\\n"); - private static final ApplicationPackage applicationPackage = new ApplicationPackageBuilder() + private static final ApplicationPackage applicationPackageDefault = new ApplicationPackageBuilder() + .instances("default") + .environment(Environment.prod) + .globalServiceId("foo") + .region("us-central-1") + .region("us-east-3") + .region("us-west-1") + .blockChange(false, true, "mon-fri", "0-8", "UTC") + .build(); + + private static final ApplicationPackage applicationPackageInstance1 = new ApplicationPackageBuilder() + .instances("instance1") .environment(Environment.prod) .globalServiceId("foo") .region("us-central-1") @@ -225,7 +232,7 @@ public class ApplicationApiTest extends ControllerContainerTest { addUserToHostedOperatorRole(HostedAthenzIdentities.from(HOSTED_VESPA_OPERATOR)); // POST (deploy) an application to a zone - manual user deployment (includes a content hash for verification) - MultiPartStreamer entity = createApplicationDeployData(applicationPackage, true); + MultiPartStreamer entity = createApplicationDeployData(applicationPackageInstance1, true); tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1/environment/dev/region/us-west-1/instance/instance1/deploy", POST) .data(entity) .header("X-Content-Hash", Base64.getEncoder().encodeToString(Signatures.sha256Digest(entity::data))) @@ -245,7 +252,7 @@ public class ApplicationApiTest extends ControllerContainerTest { controllerTester.jobCompletion(JobType.component) .application(id) .projectId(screwdriverProjectId) - .uploadArtifact(applicationPackage) + .uploadArtifact(applicationPackageInstance1) .submit(); // ... systemtest @@ -309,6 +316,7 @@ public class ApplicationApiTest extends ControllerContainerTest { // POST (create) another application ApplicationPackage applicationPackage = new ApplicationPackageBuilder() + .instances("instance1") .environment(Environment.prod) .region("us-west-1") .build(); @@ -354,7 +362,7 @@ public class ApplicationApiTest extends ControllerContainerTest { tester.assertResponse(request("/application/v4/tenant/tenant2/application/application2/key", POST) .userIdentity(USER_ID) .data("{\"key\":\"" + pemPublicKey + "\"}"), - "{\"message\":\"Added deploy key " + quotedPemPublicKey + "\"}"); + "{\"keys\":[\"-----BEGIN PUBLIC KEY-----\\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuKVFA8dXk43kVfYKzkUqhEY2rDT9\\nz/4jKSTHwbYR8wdsOSrJGVEUPbS2nguIJ64OJH7gFnxM6sxUVj+Nm2HlXw==\\n-----END PUBLIC KEY-----\\n\"]}"); // PATCH in a pem deploy key at deprecated path tester.assertResponse(request("/application/v4/tenant/tenant2/application/application2/instance/default", PATCH) @@ -377,7 +385,7 @@ public class ApplicationApiTest extends ControllerContainerTest { tester.assertResponse(request("/application/v4/tenant/tenant2/application/application2/key", DELETE) .userIdentity(USER_ID) .data("{\"key\":\"" + pemPublicKey + "\"}"), - "{\"message\":\"Removed deploy key " + quotedPemPublicKey + "\"}"); + "{\"keys\":[]}"); tester.assertResponse(request("/application/v4/tenant/tenant2/application/application2", GET) .userIdentity(USER_ID), @@ -585,6 +593,7 @@ public class ApplicationApiTest extends ControllerContainerTest { // Second attempt has a service under a different domain than the tenant of the application, and fails. ApplicationPackage packageWithServiceForWrongDomain = new ApplicationPackageBuilder() + .instances("instance1") .environment(Environment.prod) .athenzIdentity(com.yahoo.config.provision.AthenzDomain.from(ATHENZ_TENANT_DOMAIN_2.getName()), AthenzService.from("service")) .region("us-west-1") @@ -597,6 +606,7 @@ public class ApplicationApiTest extends ControllerContainerTest { // Third attempt finally has a service under the domain of the tenant, and succeeds. ApplicationPackage packageWithService = new ApplicationPackageBuilder() + .instances("instance1") .environment(Environment.prod) .athenzIdentity(com.yahoo.config.provision.AthenzDomain.from(ATHENZ_TENANT_DOMAIN.getName()), AthenzService.from("service")) .region("us-west-1") @@ -710,6 +720,7 @@ public class ApplicationApiTest extends ControllerContainerTest { tester.computeVersionStatus(); createAthenzDomainWithAdmin(ATHENZ_TENANT_DOMAIN, USER_ID); ApplicationPackage applicationPackage = new ApplicationPackageBuilder() + .instances("instance1") .globalServiceId("foo") .region("us-west-1") .region("us-east-3") @@ -718,7 +729,7 @@ public class ApplicationApiTest extends ControllerContainerTest { // Create tenant and deploy ApplicationId id = createTenantAndApplication(); long projectId = 1; - MultiPartStreamer deployData = createApplicationDeployData(Optional.empty(), false); + MultiPartStreamer deployData = createApplicationDeployData(Optional.of(applicationPackage), false); startAndTestChange(controllerTester, id, projectId, applicationPackage, deployData, 100); // us-west-1 @@ -781,6 +792,7 @@ public class ApplicationApiTest extends ControllerContainerTest { tester.computeVersionStatus(); createAthenzDomainWithAdmin(ATHENZ_TENANT_DOMAIN, USER_ID); ApplicationPackage applicationPackage = new ApplicationPackageBuilder() + .instances("instance1") .region("us-west-1") .region("us-east-3") .region("eu-west-1") @@ -857,7 +869,7 @@ public class ApplicationApiTest extends ControllerContainerTest { new com.yahoo.vespa.hosted.controller.api.identifiers.ApplicationId("application1")); // POST (deploy) an application to a prod zone - allowed when project ID is not specified - MultiPartStreamer entity = createApplicationDeployData(applicationPackage, true); + MultiPartStreamer entity = createApplicationDeployData(applicationPackageInstance1, true); tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1/environment/prod/region/us-central-1/instance/instance1/deploy", POST) .data(entity) .screwdriverIdentity(SCREWDRIVER_ID), @@ -889,6 +901,7 @@ public class ApplicationApiTest extends ControllerContainerTest { // Deploy ApplicationPackage applicationPackage = new ApplicationPackageBuilder() + .instances("instance1") .region("us-east-3") .build(); ApplicationId id = createTenantAndApplication(); @@ -908,6 +921,7 @@ public class ApplicationApiTest extends ControllerContainerTest { // New zone is added before us-east-3 applicationPackage = new ApplicationPackageBuilder() + .instances("instance1") .globalServiceId("foo") // These decides the ordering of deploymentJobs and instances in the response .region("us-west-1") @@ -953,9 +967,9 @@ public class ApplicationApiTest extends ControllerContainerTest { ResourceAllocation lastMonth = new ResourceAllocation(24, 48, 2000); ApplicationId applicationId = ApplicationId.from("doesnotexist", "doesnotexist", "default"); Map<ApplicationId, List<ResourceSnapshot>> snapshotHistory = Map.of(applicationId, List.of( - new ResourceSnapshot(applicationId, 1, 2,3, Instant.ofEpochMilli(123)), - new ResourceSnapshot(applicationId, 1, 2,3, Instant.ofEpochMilli(246)), - new ResourceSnapshot(applicationId, 1, 2,3, Instant.ofEpochMilli(492)))); + new ResourceSnapshot(applicationId, 1, 2,3, Instant.ofEpochMilli(123), ZoneId.defaultId()), + new ResourceSnapshot(applicationId, 1, 2,3, Instant.ofEpochMilli(246), ZoneId.defaultId()), + new ResourceSnapshot(applicationId, 1, 2,3, Instant.ofEpochMilli(492), ZoneId.defaultId()))); mockMeteringClient.setMeteringInfo(new MeteringInfo(thisMonth, lastMonth, currentSnapshot, snapshotHistory)); @@ -1060,7 +1074,7 @@ public class ApplicationApiTest extends ControllerContainerTest { configServer.throwOnNextPrepare(new ConfigServerException(new URI("server-url"), "Failed to prepare application", ConfigServerException.ErrorCode.INVALID_APPLICATION_PACKAGE, null)); // POST (deploy) an application with an invalid application package - MultiPartStreamer entity = createApplicationDeployData(applicationPackage, true); + MultiPartStreamer entity = createApplicationDeployData(applicationPackageInstance1, true); tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1/environment/dev/region/us-west-1/instance/instance1/deploy", POST) .data(entity) .userIdentity(USER_ID), @@ -1180,7 +1194,7 @@ public class ApplicationApiTest extends ControllerContainerTest { 200); // Deploy to an authorized zone by a user tenant is disallowed - MultiPartStreamer entity = createApplicationDeployData(applicationPackage, true); + MultiPartStreamer entity = createApplicationDeployData(applicationPackageDefault, true); tester.assertResponse(request("/application/v4/tenant/tenant1/application/application1/environment/prod/region/us-west-1/instance/default/deploy", POST) .data(entity) .userIdentity(USER_ID), @@ -1593,7 +1607,7 @@ public class ApplicationApiTest extends ControllerContainerTest { } private MultiPartStreamer createApplicationDeployData(Optional<ApplicationPackage> applicationPackage, - Optional<ApplicationVersion> applicationVersion, boolean deployDirectly) { + Optional<ApplicationVersion> applicationVersion, boolean deployDirectly) { MultiPartStreamer streamer = new MultiPartStreamer(); streamer.addJson("deployOptions", deployOptions(deployDirectly, applicationVersion)); applicationPackage.ifPresent(ap -> streamer.addBytes("applicationZip", ap.zippedContent())); @@ -1745,14 +1759,11 @@ public class ApplicationApiTest extends ControllerContainerTest { clusterInfo.put(ClusterSpec.Id.from("cluster1"), new ClusterInfo("flavor1", 37, 2, 4, 50, ClusterSpec.Type.content, hostnames)); - Map<ClusterSpec.Id, ClusterUtilization> clusterUtils = new HashMap<>(); - clusterUtils.put(ClusterSpec.Id.from("cluster1"), new ClusterUtilization(0.3, 0.6, 0.4, 0.3)); DeploymentMetrics metrics = new DeploymentMetrics(1, 2, 3, 4, 5, Optional.of(Instant.ofEpochMilli(123123)), Map.of()); lockedApplication = lockedApplication.with(instance.name(), lockedInstance -> lockedInstance.withClusterInfo(deployment.zone(), clusterInfo) - .withClusterUtilization(deployment.zone(), clusterUtils) .with(deployment.zone(), metrics) .recordActivityAt(Instant.parse("2018-06-01T10:15:30.00Z"), deployment.zone())); } @@ -1771,17 +1782,6 @@ public class ApplicationApiTest extends ControllerContainerTest { new RotationStatusUpdater(tester.controller(), Duration.ofDays(1), new JobControl(tester.controller().curator())).run(); } - private RotationStatus rotationStatus(Instance instance) { - return controllerTester.controller().applications().rotationRepository().getRotation(instance) - .map(rotation -> { - var rotationStatus = controllerTester.controller().serviceRegistry().globalRoutingService().getHealthStatus(rotation.name()); - var statusMap = new LinkedHashMap<ZoneId, RotationState>(); - rotationStatus.forEach((zone, status) -> statusMap.put(zone, RotationState.in)); - return RotationStatus.from(Map.of(rotation.id(), statusMap)); - }) - .orElse(RotationStatus.EMPTY); - } - private void updateContactInformation() { Contact contact = new Contact(URI.create("www.contacts.tld/1234"), URI.create("www.properties.tld/1234"), diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java index f2410c47908..b1f5f33b960 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java @@ -143,14 +143,14 @@ public class UserApiTest extends ControllerContainerCloudTest { tester.assertResponse(request("/application/v4/tenant/my-tenant/application/my-app/key", POST) .roles(Set.of(Role.tenantOperator(id.tenant()))) .data("{\"key\":\"" + pemPublicKey + "\"}"), - "{\"message\":\"Added deploy key " + quotedPemPublicKey + "\"}"); + new File("first-deploy-key.json")); // POST a pem developer key tester.assertResponse(request("/application/v4/tenant/my-tenant/key", POST) .user("joe@dev") .roles(Set.of(Role.tenantOperator(id.tenant()))) .data("{\"key\":\"" + pemPublicKey + "\"}"), - "{\"message\":\"Set developer key " + quotedPemPublicKey + " for joe@dev\"}"); + new File("first-developer-key.json")); // POST the same pem developer key for a different user is forbidden tester.assertResponse(request("/application/v4/tenant/my-tenant/key", POST) @@ -165,7 +165,7 @@ public class UserApiTest extends ControllerContainerCloudTest { .user("operator@tenant") .roles(Set.of(Role.tenantOperator(id.tenant()))) .data("{\"key\":\"" + otherPemPublicKey + "\"}"), - "{\"message\":\"Set developer key " + otherQuotedPemPublicKey + " for operator@tenant\"}"); + new File("both-developer-keys.json")); // GET tenant information with keys tester.assertResponse(request("/application/v4/tenant/my-tenant/") @@ -176,7 +176,7 @@ public class UserApiTest extends ControllerContainerCloudTest { tester.assertResponse(request("/application/v4/tenant/my-tenant/key", DELETE) .roles(Set.of(Role.tenantOperator(id.tenant()))) .data("{\"key\":\"" + pemPublicKey + "\"}"), - "{\"message\":\"Removed developer key " + quotedPemPublicKey + " for joe@dev\"}"); + new File("second-developer-key.json")); // DELETE an application role is allowed for an application admin. tester.assertResponse(request("/user/v1/tenant/my-tenant/application/my-app", DELETE) diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/both-developer-keys.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/both-developer-keys.json new file mode 100644 index 00000000000..2ff1c29fe29 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/both-developer-keys.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuKVFA8dXk43kVfYKzkUqhEY2rDT9\nz/4jKSTHwbYR8wdsOSrJGVEUPbS2nguIJ64OJH7gFnxM6sxUVj+Nm2HlXw==\n-----END PUBLIC KEY-----\n", + "user": "joe@dev" + }, + { + "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFELzPyinTfQ/sZnTmRp5E4Ve/sbE\npDhJeqczkyFcT2PysJ5sZwm7rKPEeXDOhzTPCyRvbUqc2SGdWbKUGGa/Yw==\n-----END PUBLIC KEY-----\n", + "user": "operator@tenant" + } + ] +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/first-deploy-key.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/first-deploy-key.json new file mode 100644 index 00000000000..1c86877b77d --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/first-deploy-key.json @@ -0,0 +1,5 @@ +{ + "keys": [ + "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuKVFA8dXk43kVfYKzkUqhEY2rDT9\nz/4jKSTHwbYR8wdsOSrJGVEUPbS2nguIJ64OJH7gFnxM6sxUVj+Nm2HlXw==\n-----END PUBLIC KEY-----\n" + ] +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/first-developer-key.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/first-developer-key.json new file mode 100644 index 00000000000..b7d48f283f3 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/first-developer-key.json @@ -0,0 +1,9 @@ +{ + "keys": [ + { + "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuKVFA8dXk43kVfYKzkUqhEY2rDT9\nz/4jKSTHwbYR8wdsOSrJGVEUPbS2nguIJ64OJH7gFnxM6sxUVj+Nm2HlXw==\n-----END PUBLIC KEY-----\n", + "user": "joe@dev" + } + ] +} + diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/second-developer-key.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/second-developer-key.json new file mode 100644 index 00000000000..f7d90f31116 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/second-developer-key.json @@ -0,0 +1,8 @@ +{ + "keys": [ + { + "key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFELzPyinTfQ/sZnTmRp5E4Ve/sbE\npDhJeqczkyFcT2PysJ5sZwm7rKPEeXDOhzTPCyRvbUqc2SGdWbKUGGa/Yw==\n-----END PUBLIC KEY-----\n", + "user": "operator@tenant" + } + ] +} diff --git a/flags/src/main/java/com/yahoo/vespa/flags/Flags.java b/flags/src/main/java/com/yahoo/vespa/flags/Flags.java index ae782bf32ff..4b1befc1770 100644 --- a/flags/src/main/java/com/yahoo/vespa/flags/Flags.java +++ b/flags/src/main/java/com/yahoo/vespa/flags/Flags.java @@ -82,9 +82,14 @@ public class Flags { public static final UnboundBooleanFlag INCLUDE_SIS_IN_TRUSTSTORE = defineFeatureFlag( "include-sis-in-truststore", false, - "Whether to use the trust store backed by Athenz and Service Identity certificates.", - "Takes effect on next tick, but may get throttled due to orchestration.", - HOSTNAME); + "Whether to use the trust store backed by Athenz and (in public) Service Identity certificates in " + + "host-admin and/or Docker containers", + "Takes effect on restart of host-admin (for host-admin), and restart of Docker container.", + // For host-admin, HOSTNAME and NODE_TYPE is available + // For Docker containers, HOSTNAME and APPLICATION_ID is available + // WARNING: Having different sets of dimensions is DISCOURAGED in general, but needed for here since + // trust store for host-admin is determined before having access to application ID from node repo. + HOSTNAME, NODE_TYPE, APPLICATION_ID); public static final UnboundStringFlag TLS_INSECURE_MIXED_MODE = defineStringFlag( "tls-insecure-mixed-mode", "tls_client_mixed_server", diff --git a/hosted-api/src/main/java/ai/vespa/hosted/api/Properties.java b/hosted-api/src/main/java/ai/vespa/hosted/api/Properties.java index 0ca1b3e5603..9c7380180f7 100644 --- a/hosted-api/src/main/java/ai/vespa/hosted/api/Properties.java +++ b/hosted-api/src/main/java/ai/vespa/hosted/api/Properties.java @@ -16,38 +16,62 @@ import java.util.Optional; */ public class Properties { + /** + * Returns the relevant application ID. This is the 'tenant', 'application' and 'instance' properties. + * The instance defaults to the user name of the current user, if not explicitly set. + */ public static ApplicationId application() { return ApplicationId.from(requireNonBlankProperty("tenant"), requireNonBlankProperty("application"), - getNonBlankProperty("instance").orElse("default")); + getNonBlankProperty("instance").orElse(user())); } + /** Returns the relevant environment, if this is set with the 'environment' property */ public static Optional<Environment> environment() { return getNonBlankProperty("environment").map(Environment::from); } + /** Returns the relevant region, if this is set with the 'region' property */ public static Optional<RegionName> region() { return getNonBlankProperty("region").map(RegionName::from); } - public static URI endpoint() { + /** Returns the URL of the API endpoint of the Vespa cloud. This must be set with the 'endpoint' property. */ + public static URI apiEndpoint() { return URI.create(requireNonBlankProperty("endpoint")); } - public static Path privateKeyFile() { + /** Returns the path of the API private key. This must be set with the 'privateKeyFile' property. */ + public static Path apiPrivateKeyFile() { return Paths.get(requireNonBlankProperty("privateKeyFile")); } - public static Optional<Path> certificateFile() { + /** Returns the path of the API certificate, if this is set with the 'certificateFile' property. */ + public static Optional<Path> apiCertificateFile() { return getNonBlankProperty("certificateFile").map(Paths::get); } + /** Returns the path of the data plane certificate file, if this is set with the 'dataPlaneCertificateFile' property. */ + public static Optional<Path> dataPlaneCertificateFile() { + return getNonBlankProperty("dataPlaneCertificateFile").map(Paths::get); + } + + /** Returns the path of the data plane private key file, if this is set with the 'dataPlanePrivateKeyFile' property. */ + public static Optional<Path> dataPlanePrivateKeyFile() { + return getNonBlankProperty("dataPlaneKeyFile").map(Paths::get); + } + + /** Returns the user name of the current user. This is set with the 'user.name' property. */ + public static String user() { + return System.getProperty("user.name"); + } + /** Returns the system property with the given name if it is set, or empty. */ public static Optional<String> getNonBlankProperty(String name) { return Optional.ofNullable(System.getProperty(name)).filter(value -> ! value.isBlank()); } - /** Returns the system property with the given name if it is set, or throws. */ + /** Returns the system property with the given name if it is set, or throws an IllegalStateException. */ public static String requireNonBlankProperty(String name) { return getNonBlankProperty(name).orElseThrow(() -> new IllegalStateException("Missing required property '" + name + "'")); } diff --git a/metrics/src/vespa/metrics/metric.cpp b/metrics/src/vespa/metrics/metric.cpp index 20083300271..a7a212d759c 100644 --- a/metrics/src/vespa/metrics/metric.cpp +++ b/metrics/src/vespa/metrics/metric.cpp @@ -13,6 +13,7 @@ #include <cassert> #include <algorithm> #include <ostream> +#include <regex> namespace metrics { @@ -39,10 +40,9 @@ MetricVisitor::visitMetric(const Metric&, bool) namespace { std::string namePattern = "[a-zA-Z][_a-zA-Z0-9]*"; + std::regex name_pattern_regex(namePattern); } -vespalib::Regexp Metric::_namePattern(namePattern); - Tag::Tag(vespalib::stringref k) : _key(NameRepo::tagKeyId(k)), _value(TagValueId::empty_handle) @@ -143,7 +143,8 @@ Metric::verifyConstructionParameters() throw vespalib::IllegalArgumentException( "Metric cannot have empty name", VESPA_STRLOC); } - if (!_namePattern.match(getName())) { + const auto &name = getName(); + if (!std::regex_search(name.c_str(), name.c_str() + name.size(), name_pattern_regex)) { throw vespalib::IllegalArgumentException( "Illegal metric name '" + getName() + "'. Names must match pattern " + namePattern, VESPA_STRLOC); diff --git a/metrics/src/vespa/metrics/metric.h b/metrics/src/vespa/metrics/metric.h index 85832ba08d1..845f40a335b 100644 --- a/metrics/src/vespa/metrics/metric.h +++ b/metrics/src/vespa/metrics/metric.h @@ -3,7 +3,6 @@ #include <vespa/vespalib/util/printable.h> #include <vespa/vespalib/stllike/string.h> -#include <vespa/vespalib/util/regexp.h> #include "name_repo.h" namespace metrics { @@ -110,8 +109,6 @@ public: using SP = std::shared_ptr<Metric>; using Tags = std::vector<Tag>; - static vespalib::Regexp _namePattern; - Metric(const String& name, Tags dimensions, const String& description, diff --git a/metrics/src/vespa/metrics/textwriter.cpp b/metrics/src/vespa/metrics/textwriter.cpp index 9ce1005821f..4edfb93b452 100644 --- a/metrics/src/vespa/metrics/textwriter.cpp +++ b/metrics/src/vespa/metrics/textwriter.cpp @@ -11,8 +11,13 @@ namespace metrics { TextWriter::TextWriter(std::ostream& out, uint32_t period, const std::string& regex, bool verbose) - : _period(period), _out(out), _regex(regex), _verbose(verbose) -{ } + : _period(period), _out(out), _regex(), _verbose(verbose) +{ + try { + _regex = std::regex(regex); + } catch (std::regex_error &) { + } +} TextWriter::~TextWriter() { } @@ -50,7 +55,7 @@ TextWriter::writeCommon(const Metric& metric) } std::string mypath(path.str()); path << metric.getMangledName(); - if (_regex.match(path.str())) { + if (_regex && std::regex_search(path.str(), *_regex)) { if (metric.used() || _verbose) { _out << "\n" << mypath; return true; diff --git a/metrics/src/vespa/metrics/textwriter.h b/metrics/src/vespa/metrics/textwriter.h index c4267f07197..b1f09d1f0ed 100644 --- a/metrics/src/vespa/metrics/textwriter.h +++ b/metrics/src/vespa/metrics/textwriter.h @@ -3,7 +3,8 @@ #pragma once #include "metric.h" -#include <vespa/vespalib/util/regexp.h> +#include <regex> +#include <optional> namespace metrics { @@ -11,7 +12,7 @@ class TextWriter : public MetricVisitor { uint32_t _period; std::ostream& _out; std::vector<std::string> _path; - vespalib::Regexp _regex; + std::optional<std::regex> _regex; bool _verbose; public: diff --git a/security-utils/src/main/java/com/yahoo/security/SubjectAlternativeName.java b/security-utils/src/main/java/com/yahoo/security/SubjectAlternativeName.java index 29395c75e70..81581c8146c 100644 --- a/security-utils/src/main/java/com/yahoo/security/SubjectAlternativeName.java +++ b/security-utils/src/main/java/com/yahoo/security/SubjectAlternativeName.java @@ -3,10 +3,13 @@ package com.yahoo.security; import org.bouncycastle.asn1.ASN1Encodable; import org.bouncycastle.asn1.DERIA5String; +import org.bouncycastle.asn1.DEROctetString; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.GeneralName; import org.bouncycastle.asn1.x509.GeneralNames; +import java.net.InetAddress; +import java.net.UnknownHostException; import java.util.Arrays; import java.util.List; import java.util.Objects; @@ -43,6 +46,10 @@ public class SubjectAlternativeName { return new GeneralName(type.tag, value); } + public SubjectAlternativeName decode() { + return new SubjectAlternativeName(new GeneralName(type.tag, value)); + } + static List<SubjectAlternativeName> fromGeneralNames(GeneralNames generalNames) { return Arrays.stream(generalNames.getNames()).map(SubjectAlternativeName::new).collect(toList()); } @@ -56,6 +63,14 @@ public class SubjectAlternativeName { return DERIA5String.getInstance(name).getString(); case GeneralName.directoryName: return X500Name.getInstance(name).toString(); + case GeneralName.iPAddress: + var octets = DEROctetString.getInstance(name.toASN1Primitive()).getOctets(); + try { + return InetAddress.getByAddress(octets).getHostAddress(); + } catch (UnknownHostException e) { + // Only thrown if IP address is of invalid length, which is an illegal argument + throw new IllegalArgumentException(e); + } default: return name.toString(); } diff --git a/storage/src/vespa/storage/distributor/distributor.cpp b/storage/src/vespa/storage/distributor/distributor.cpp index 4adbdd32669..ab6776717aa 100644 --- a/storage/src/vespa/storage/distributor/distributor.cpp +++ b/storage/src/vespa/storage/distributor/distributor.cpp @@ -77,8 +77,7 @@ Distributor::Distributor(DistributorComponentRegister& compReg, _distributorStatusDelegate(compReg, *this, *this), _bucketDBStatusDelegate(compReg, *this, _bucketDBUpdater), _idealStateManager(*this, *_bucketSpaceRepo, *_readOnlyBucketSpaceRepo, compReg, manageActiveBucketCopies), - _externalOperationHandler(*this, *_bucketSpaceRepo, *_readOnlyBucketSpaceRepo, - _idealStateManager, compReg, use_btree_database), + _externalOperationHandler(*this, *_bucketSpaceRepo, *_readOnlyBucketSpaceRepo, _idealStateManager, compReg), _threadPool(threadPool), _initializingIsUp(true), _doneInitializeHandler(doneInitHandler), diff --git a/storage/src/vespa/storage/distributor/externaloperationhandler.cpp b/storage/src/vespa/storage/distributor/externaloperationhandler.cpp index 221c516a56e..6b476ae37c5 100644 --- a/storage/src/vespa/storage/distributor/externaloperationhandler.cpp +++ b/storage/src/vespa/storage/distributor/externaloperationhandler.cpp @@ -30,14 +30,12 @@ ExternalOperationHandler::ExternalOperationHandler(Distributor& owner, DistributorBucketSpaceRepo& bucketSpaceRepo, DistributorBucketSpaceRepo& readOnlyBucketSpaceRepo, const MaintenanceOperationGenerator& gen, - DistributorComponentRegister& compReg, - bool enable_concurrent_gets) + DistributorComponentRegister& compReg) : DistributorComponent(owner, bucketSpaceRepo, readOnlyBucketSpaceRepo, compReg, "External operation handler"), _operationGenerator(gen), _rejectFeedBeforeTimeReached(), // At epoch _non_main_thread_ops_mutex(), - _non_main_thread_ops_owner(owner, getClock()), - _enable_concurrent_gets(enable_concurrent_gets) + _non_main_thread_ops_owner(owner, getClock()) { } diff --git a/storage/src/vespa/storage/distributor/externaloperationhandler.h b/storage/src/vespa/storage/distributor/externaloperationhandler.h index 9db078af198..b64b4bc90cd 100644 --- a/storage/src/vespa/storage/distributor/externaloperationhandler.h +++ b/storage/src/vespa/storage/distributor/externaloperationhandler.h @@ -40,8 +40,7 @@ public: DistributorBucketSpaceRepo& bucketSpaceRepo, DistributorBucketSpaceRepo& readOnlyBucketSpaceRepo, const MaintenanceOperationGenerator&, - DistributorComponentRegister& compReg, - bool enable_concurrent_gets); + DistributorComponentRegister& compReg); ~ExternalOperationHandler() override; @@ -59,7 +58,6 @@ private: TimePoint _rejectFeedBeforeTimeReached; mutable std::mutex _non_main_thread_ops_mutex; OperationOwner _non_main_thread_ops_owner; - bool _enable_concurrent_gets; template <typename Func> void bounce_or_invoke_read_only_op(api::StorageCommand& cmd, diff --git a/tenant-auth/src/main/java/ai/vespa/hosted/auth/ApiAuthenticator.java b/tenant-auth/src/main/java/ai/vespa/hosted/auth/ApiAuthenticator.java index 9de06e7f4da..f6a88ec83c2 100644 --- a/tenant-auth/src/main/java/ai/vespa/hosted/auth/ApiAuthenticator.java +++ b/tenant-auth/src/main/java/ai/vespa/hosted/auth/ApiAuthenticator.java @@ -8,13 +8,13 @@ public class ApiAuthenticator implements ai.vespa.hosted.api.ApiAuthenticator { /** Returns a controller client using mTLS if a key and certificate pair is provided, or signed requests otherwise. */ @Override public ControllerHttpClient controller() { - return Properties.certificateFile() - .map(certificateFile -> ControllerHttpClient.withKeyAndCertificate(Properties.endpoint(), - Properties.privateKeyFile(), + return Properties.apiCertificateFile() + .map(certificateFile -> ControllerHttpClient.withKeyAndCertificate(Properties.apiEndpoint(), + Properties.apiPrivateKeyFile(), certificateFile)) .orElseGet(() -> - ControllerHttpClient.withSignatureKey(Properties.endpoint(), - Properties.privateKeyFile(), + ControllerHttpClient.withSignatureKey(Properties.apiEndpoint(), + Properties.apiPrivateKeyFile(), Properties.application())); } diff --git a/tenant-auth/src/main/java/ai/vespa/hosted/auth/EndpointAuthenticator.java b/tenant-auth/src/main/java/ai/vespa/hosted/auth/EndpointAuthenticator.java index c9640763ac8..e51476907e2 100644 --- a/tenant-auth/src/main/java/ai/vespa/hosted/auth/EndpointAuthenticator.java +++ b/tenant-auth/src/main/java/ai/vespa/hosted/auth/EndpointAuthenticator.java @@ -1,5 +1,6 @@ package ai.vespa.hosted.auth; +import ai.vespa.hosted.api.Properties; import com.yahoo.config.provision.SystemName; import com.yahoo.security.KeyUtils; import com.yahoo.security.SslContextBuilder; @@ -47,12 +48,10 @@ public class EndpointAuthenticator implements ai.vespa.hosted.api.EndpointAuthen privateKeyFile = credentialsRoot.resolve("key"); } else { - Optional<String> certificateFileProperty = getNonBlankProperty("dataPlaneCertificateFile"); - if (certificateFileProperty.isPresent()) - certificateFile = Path.of(certificateFileProperty.get()); - Optional<String> privateKeyFileProperty = getNonBlankProperty("dataPlaneKeyFile"); - if (privateKeyFileProperty.isPresent()) - privateKeyFile = Path.of(privateKeyFileProperty.get()); + if (Properties.dataPlaneCertificateFile().isPresent()) + certificateFile = Properties.dataPlaneCertificateFile().get(); + if (Properties.dataPlanePrivateKeyFile().isPresent()) + privateKeyFile = Properties.dataPlanePrivateKeyFile().get(); } if (certificateFile != null && privateKeyFile != null) { X509Certificate certificate = X509CertificateUtils.fromPem(new String(Files.readAllBytes(certificateFile))); @@ -67,7 +66,7 @@ public class EndpointAuthenticator implements ai.vespa.hosted.api.EndpointAuthen logger.warning( "##################################################################################\n" + "# Data plane key and/or certificate missing; please specify #\n" + "# '-DdataPlaneCertificateFile=/path/to/certificate' and #\n" - + "# '-DdataPlaneKeyFile=/path/to/private_key. #\n" + + "# '-DdataPlaneKeyFile=/path/to/private_key'. #\n" + "# Trying the default SSLContext, but this will most likely cause HTTP error 401. #\n" + "##################################################################################"); return SSLContext.getDefault(); diff --git a/vdslib/src/tests/distribution/distributiontest.cpp b/vdslib/src/tests/distribution/distributiontest.cpp index 80f28af17b5..c43735e7e41 100644 --- a/vdslib/src/tests/distribution/distributiontest.cpp +++ b/vdslib/src/tests/distribution/distributiontest.cpp @@ -13,7 +13,6 @@ #include <vespa/vespalib/io/fileutil.h> #include <vespa/vespalib/stllike/lexical_cast.h> #include <vespa/vespalib/text/stringtokenizer.h> -#include <vespa/vespalib/util/regexp.h> #include <chrono> #include <thread> #include <fstream> diff --git a/vespa-maven-plugin/src/main/java/ai/vespa/hosted/plugin/AbstractVespaMojo.java b/vespa-maven-plugin/src/main/java/ai/vespa/hosted/plugin/AbstractVespaMojo.java index bf8f6f83f53..3f6817df96d 100644 --- a/vespa-maven-plugin/src/main/java/ai/vespa/hosted/plugin/AbstractVespaMojo.java +++ b/vespa-maven-plugin/src/main/java/ai/vespa/hosted/plugin/AbstractVespaMojo.java @@ -1,6 +1,7 @@ package ai.vespa.hosted.plugin; import ai.vespa.hosted.api.ControllerHttpClient; +import ai.vespa.hosted.api.Properties; import com.yahoo.config.provision.ApplicationId; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; @@ -64,7 +65,7 @@ public abstract class AbstractVespaMojo extends AbstractMojo { protected void setup() { tenant = firstNonBlank(tenant, project.getProperties().getProperty("tenant")); application = firstNonBlank(application, project.getProperties().getProperty("application")); - instance = firstNonBlank(instance, project.getProperties().getProperty("instance", "default")); + instance = firstNonBlank(instance, project.getProperties().getProperty("instance", Properties.user())); id = ApplicationId.from(tenant, application, instance); controller = certificateFile == null |