// Copyright Yahoo. 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.CloudAccount; import com.yahoo.config.provision.ClusterSpec; import com.yahoo.config.provision.Environment; import com.yahoo.config.provision.InstanceName; import com.yahoo.config.provision.RegionName; import com.yahoo.config.provision.ZoneEndpoint; import com.yahoo.config.provision.ZoneEndpoint.AllowedUrn; import com.yahoo.config.provision.ZoneEndpoint.AccessType; import org.junit.Test; import java.io.StringReader; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.util.Collections; import java.util.List; import java.util.Map; 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 com.yahoo.config.provision.CloudName.AWS; import static com.yahoo.config.provision.Environment.dev; import static com.yahoo.config.provision.Environment.prod; import static com.yahoo.config.provision.Environment.test; import static com.yahoo.config.provision.zone.ZoneId.defaultId; import static com.yahoo.config.provision.zone.ZoneId.from; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; /** * @author bratseth */ public class DeploymentSpecWithoutInstanceTest { @Test public void testSpec() { String specXml = "" + " " + ""; 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).concerns(test)); assertTrue(spec.requireInstance("default").concerns(test, Optional.empty())); assertTrue(spec.requireInstance("default").concerns(test, Optional.of(RegionName.from("region1")))); // test steps specify no region assertFalse(spec.requireInstance("default").concerns(Environment.staging, Optional.empty())); assertFalse(spec.requireInstance("default").concerns(prod, Optional.empty())); assertFalse(spec.requireInstance("default").globalServiceId().isPresent()); } @Test public void testSpecPinningMajorVersion() { String specXml = "" + " " + ""; 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( "" + " " + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals(1, spec.steps().size()); assertEquals(1, spec.requireInstance("default").steps().size()); assertTrue(spec.requireInstance("default").steps().get(0).concerns(Environment.staging)); assertFalse(spec.requireInstance("default").concerns(test, Optional.empty())); assertTrue(spec.requireInstance("default").concerns(Environment.staging, Optional.empty())); assertFalse(spec.requireInstance("default").concerns(prod, Optional.empty())); assertFalse(spec.requireInstance("default").globalServiceId().isPresent()); } @Test public void minimalProductionSpec() { StringReader r = new StringReader( "" + " " + " us-east1" + " us-west1" + " " + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals(1, spec.steps().size()); assertEquals(2, spec.requireInstance("default").steps().size()); assertTrue(spec.requireInstance("default").steps().get(0).concerns(prod, Optional.of(RegionName.from("us-east1")))); assertFalse(((DeploymentSpec.DeclaredZone)spec.requireInstance("default").steps().get(0)).active()); assertTrue(spec.requireInstance("default").steps().get(1).concerns(prod, Optional.of(RegionName.from("us-west1")))); assertTrue(((DeploymentSpec.DeclaredZone)spec.requireInstance("default").steps().get(1)).active()); assertFalse(spec.requireInstance("default").concerns(test, Optional.empty())); assertFalse(spec.requireInstance("default").concerns(Environment.staging, Optional.empty())); assertTrue(spec.requireInstance("default").concerns(prod, Optional.of(RegionName.from("us-east1")))); assertTrue(spec.requireInstance("default").concerns(prod, Optional.of(RegionName.from("us-west1")))); assertFalse(spec.requireInstance("default").concerns(prod, Optional.of(RegionName.from("no-such-region")))); assertFalse(spec.requireInstance("default").globalServiceId().isPresent()); assertEquals(DeploymentSpec.UpgradePolicy.defaultPolicy, spec.requireInstance("default").upgradePolicy()); assertEquals(DeploymentSpec.UpgradeRollout.separate, spec.requireInstance("default").upgradeRollout()); } @Test public void maximalProductionSpec() { StringReader r = new StringReader( "" + " " + " " + " " + " us-east1" + " " + " us-west1" + " " + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals(5, spec.requireInstance("default").steps().size()); assertEquals(4, spec.requireInstance("default").zones().size()); assertTrue(spec.requireInstance("default").steps().get(0).concerns(test)); assertTrue(spec.requireInstance("default").steps().get(1).concerns(Environment.staging)); assertTrue(spec.requireInstance("default").steps().get(2).concerns(prod, Optional.of(RegionName.from("us-east1")))); assertFalse(((DeploymentSpec.DeclaredZone)spec.requireInstance("default").steps().get(2)).active()); assertTrue(spec.requireInstance("default").steps().get(3) instanceof DeploymentSpec.Delay); assertEquals(3 * 60 * 60 + 30 * 60, spec.requireInstance("default").steps().get(3).delay().getSeconds()); assertTrue(spec.requireInstance("default").steps().get(4).concerns(prod, Optional.of(RegionName.from("us-west1")))); assertTrue(((DeploymentSpec.DeclaredZone)spec.requireInstance("default").steps().get(4)).active()); assertTrue(spec.requireInstance("default").concerns(test, Optional.empty())); assertTrue(spec.requireInstance("default").concerns(test, Optional.of(RegionName.from("region1")))); // test steps specify no region assertTrue(spec.requireInstance("default").concerns(Environment.staging, Optional.empty())); assertTrue(spec.requireInstance("default").concerns(prod, Optional.of(RegionName.from("us-east1")))); assertTrue(spec.requireInstance("default").concerns(prod, Optional.of(RegionName.from("us-west1")))); assertFalse(spec.requireInstance("default").concerns(prod, Optional.of(RegionName.from("no-such-region")))); assertFalse(spec.requireInstance("default").globalServiceId().isPresent()); } @Test public void productionTests() { StringReader r = new StringReader( "" + " " + " " + " " + " us-east-1" + " us-west-1" + " " + " us-west-1" + " us-east-1" + " " + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); List instanceSteps = spec.steps().get(0).steps(); assertEquals(7, instanceSteps.size()); assertEquals("test", instanceSteps.get(0).toString()); assertEquals("staging", instanceSteps.get(1).toString()); assertEquals("prod.us-east-1", instanceSteps.get(2).toString()); assertEquals("prod.us-west-1", instanceSteps.get(3).toString()); assertEquals("delay PT1H", instanceSteps.get(4).toString()); assertEquals("tests for prod.us-west-1", instanceSteps.get(5).toString()); assertEquals("tests for prod.us-east-1", instanceSteps.get(6).toString()); } @Test(expected = IllegalArgumentException.class) public void duplicateProductionTests() { StringReader r = new StringReader( "" + " " + " us-east1" + " us-east1" + " us-east1" + " " + "" ); DeploymentSpec.fromXml(r); } @Test(expected = IllegalArgumentException.class) public void productionTestBeforeDeployment() { StringReader r = new StringReader( "" + " " + " us-east1" + " us-east1" + " " + "" ); DeploymentSpec.fromXml(r); } @Test(expected = IllegalArgumentException.class) public void productionTestInParallelWithDeployment() { StringReader r = new StringReader( "" + " " + " " + " us-east1" + " us-east1" + " " + " " + "" ); DeploymentSpec.fromXml(r); } @Test public void productionSpecWithGlobalServiceId() { StringReader r = new StringReader( "" + " " + " us-east-1" + " us-west-1" + " " + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals(spec.requireInstance("default").globalServiceId(), Optional.of("query")); } @Test(expected=IllegalArgumentException.class) public void globalServiceIdInTest() { StringReader r = new StringReader( "" + " " + "" ); DeploymentSpec.fromXml(r); } @Test(expected=IllegalArgumentException.class) public void globalServiceIdInStaging() { StringReader r = new StringReader( "" + " " + "" ); DeploymentSpec.fromXml(r); } @Test public void productionSpecWithGlobalServiceIdBeforeStaging() { StringReader r = new StringReader( "" + " " + " " + " us-west-1" + " us-central-1" + " us-east-3" + " " + " " + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals("qrs", spec.requireInstance("default").globalServiceId().get()); } @Test public void productionSpecWithUpgradeRollout() { StringReader r = new StringReader( "" + " " + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals("leading", spec.requireInstance("default").upgradeRollout().toString()); } @Test public void productionSpecWithUpgradePolicy() { StringReader r = new StringReader( "" + " " + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals("canary", spec.requireInstance("default").upgradePolicy().toString()); } @Test public void maxDelayExceeded() { try { StringReader r = new StringReader( "" + " " + " " + " us-west-1" + " " + " us-central-1" + " " + " us-east-3" + " " + "" ); DeploymentSpec.fromXml(r); fail("Expected exception due to exceeding the max total delay"); } catch (IllegalArgumentException e) { // success assertEquals("The total delay specified is PT48H1S but max 48 hours is allowed", e.getMessage()); } } @Test public void testEmpty() { assertEquals(DeploymentSpec.empty, DeploymentSpec.fromXml("\n")); assertEquals(0, DeploymentSpec.empty.steps().size()); assertTrue(DeploymentSpec.empty.athenzDomain().isEmpty()); assertTrue(DeploymentSpec.empty.athenzService().isEmpty()); assertEquals("", DeploymentSpec.empty.xmlForm()); } @Test public void testOnlyAthenzServiceDefined() { StringReader r = new StringReader( "" + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals("domain", spec.athenzDomain().get().value()); assertEquals(List.of(), spec.instances()); } @Test public void productionSpecWithParallelDeployments() { StringReader r = new StringReader( "\n" + " \n" + " us-west-1\n" + " \n" + " us-central-1\n" + " us-east-3\n" + " \n" + " \n" + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); DeploymentSpec.ParallelSteps parallelSteps = ((DeploymentSpec.ParallelSteps) spec.requireInstance("default").steps().get(1)); assertEquals(2, parallelSteps.zones().size()); assertEquals(RegionName.from("us-central-1"), parallelSteps.zones().get(0).region().get()); assertEquals(RegionName.from("us-east-3"), parallelSteps.zones().get(1).region().get()); } @Test public void testNestedParallelAndSteps() { StringReader r = new StringReader( "" + " " + " " + " " + " us-west-1" + " " + " us-east-3" + " " + " eu-west-1" + " " + " " + " " + " " + " aws-us-east-1a" + " " + " ap-northeast-1" + " ap-southeast-2" + " aws-us-east-1a" + " " + " " + " " + " " + " us-north-7" + " " + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); List steps = spec.steps(); assertEquals(1, steps.size()); assertEquals("instance 'default'", steps.get(0).toString()); assertEquals(Duration.ofHours(4), steps.get(0).delay()); List instanceSteps = steps.get(0).steps(); assertEquals(3, instanceSteps.size()); assertEquals("staging", instanceSteps.get(0).toString()); assertEquals("4 parallel steps", instanceSteps.get(1).toString()); assertEquals("prod.us-north-7", instanceSteps.get(2).toString()); List parallelSteps = instanceSteps.get(1).steps(); assertEquals(4, parallelSteps.size()); assertEquals("prod.us-west-1", parallelSteps.get(0).toString()); assertEquals("4 steps", parallelSteps.get(1).toString()); assertEquals("3 steps", parallelSteps.get(2).toString()); assertEquals("delay PT3H30M", parallelSteps.get(3).toString()); List firstSerialSteps = parallelSteps.get(1).steps(); assertEquals(4, firstSerialSteps.size()); assertEquals("prod.us-east-3", firstSerialSteps.get(0).toString()); assertEquals("delay PT2H", firstSerialSteps.get(1).toString()); assertEquals("prod.eu-west-1", firstSerialSteps.get(2).toString()); assertEquals("delay PT2H", firstSerialSteps.get(3).toString()); List secondSerialSteps = parallelSteps.get(2).steps(); assertEquals(3, secondSerialSteps.size()); assertEquals("delay PT3H", secondSerialSteps.get(0).toString()); assertEquals("prod.aws-us-east-1a", secondSerialSteps.get(1).toString()); assertEquals("3 parallel steps", secondSerialSteps.get(2).toString()); List innerParallelSteps = secondSerialSteps.get(2).steps(); assertEquals(3, innerParallelSteps.size()); assertEquals("prod.ap-northeast-1", innerParallelSteps.get(0).toString()); assertEquals("no-service", spec.requireInstance("default").athenzService(prod, RegionName.from("ap-northeast-1")).get().value()); assertEquals("prod.ap-southeast-2", innerParallelSteps.get(1).toString()); assertEquals("service", spec.requireInstance("default").athenzService(prod, RegionName.from("ap-southeast-2")).get().value()); assertEquals("tests for prod.aws-us-east-1a", innerParallelSteps.get(2).toString()); } @Test public void productionSpecWithDuplicateRegions() { StringReader r = new StringReader( "\n" + " \n" + " us-west-1\n" + " \n" + " us-west-1\n" + " us-central-1\n" + " us-east-3\n" + " \n" + " \n" + "" ); 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( "\n" + " \n" + " \n" + " us-west-1\n" + " \n" + " \n" + "" ); DeploymentSpec.fromXml(r); } @Test(expected = IllegalArgumentException.class) public void deploymentSpecWithIllegallyOrderedDeploymentSpec2() { StringReader r = new StringReader( "\n" + " \n" + " \n" + " \n" + " us-west-1\n" + " \n" + "" ); DeploymentSpec.fromXml(r); } @Test public void deploymentSpecWithChangeBlocker() { StringReader r = new StringReader( "\n" + " \n" + " \n" + " \n" + " us-west-1\n" + " \n" + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals(2, spec.requireInstance("default").changeBlocker().size()); assertTrue(spec.requireInstance("default").changeBlocker().get(0).blocksVersions()); assertFalse(spec.requireInstance("default").changeBlocker().get(0).blocksRevisions()); assertEquals(ZoneId.of("UTC"), spec.requireInstance("default").changeBlocker().get(0).window().zone()); assertTrue(spec.requireInstance("default").changeBlocker().get(1).blocksVersions()); assertTrue(spec.requireInstance("default").changeBlocker().get(1).blocksRevisions()); assertEquals(ZoneId.of("CET"), spec.requireInstance("default").changeBlocker().get(1).window().zone()); assertTrue(spec.requireInstance("default").canUpgradeAt(Instant.parse("2017-09-18T14:15:30.00Z"))); assertFalse(spec.requireInstance("default").canUpgradeAt(Instant.parse("2017-09-18T15:15:30.00Z"))); assertFalse(spec.requireInstance("default").canUpgradeAt(Instant.parse("2017-09-18T16:15:30.00Z"))); assertTrue(spec.requireInstance("default").canUpgradeAt(Instant.parse("2017-09-18T17:15:30.00Z"))); assertTrue(spec.requireInstance("default").canUpgradeAt(Instant.parse("2017-09-23T09:15:30.00Z"))); assertFalse(spec.requireInstance("default").canUpgradeAt(Instant.parse("2017-09-23T08:15:30.00Z"))); // 10 in CET assertTrue(spec.requireInstance("default").canUpgradeAt(Instant.parse("2017-09-23T10:15:30.00Z"))); } @Test public void athenz_config_is_read_from_deployment() { StringReader r = new StringReader( "\n" + " \n" + " us-west-1\n" + " \n" + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals(spec.athenzDomain().get().value(), "domain"); assertEquals(spec.requireInstance("default").athenzService(prod, RegionName.from("us-west-1")).get().value(), "service"); } @Test public void athenz_config_is_propagated_through_parallel_zones() { StringReader r = new StringReader( "" + " " + " us-central-1" + " " + " us-west-1" + " us-east-3" + " " + " " + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals("domain", spec.athenzDomain().get().value()); assertEquals("service", spec.athenzService().get().value()); assertEquals("prod-service", spec.requireInstance("default").athenzService(prod, RegionName.from("us-central-1")) .get().value()); assertEquals("prod-service", spec.requireInstance("default").athenzService(prod, RegionName.from("us-west-1")) .get().value()); assertEquals("prod-service", spec.requireInstance("default").athenzService(prod, RegionName.from("us-east-3")) .get().value()); } @Test public void athenz_service_is_overridden_from_environment() { StringReader r = new StringReader( "\n" + " \n" + " \n" + " \n" + " us-west-1\n" + " \n" + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals("service", spec.athenzService().get().value()); assertEquals(spec.athenzDomain().get().value(), "domain"); assertEquals(spec.requireInstance("default").athenzService(test, RegionName.from("us-east-1")).get().value(), "service"); assertEquals(spec.requireInstance("default").athenzService(Environment.staging, RegionName.from("us-north-1")).get().value(), "staging-service"); assertEquals(spec.requireInstance("default").athenzService(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( "\n" + " \n" + " us-west-1\n" + " \n" + "" ); DeploymentSpec.fromXml(r); } @Test(expected = IllegalArgumentException.class) public void it_fails_when_athenz_service_is_configured_but_not_athenz_domain() { StringReader r = new StringReader( "\n" + " \n" + " us-west-1\n" + " \n" + "" ); DeploymentSpec.fromXml(r); } @Test public void emptySpecs() { assertEquals(DeploymentSpec.empty, DeploymentSpec.fromXml("\n" + "")); assertEquals(DeploymentSpec.empty, DeploymentSpec.fromXml("")); assertEquals(DeploymentSpec.empty, DeploymentSpec.fromXml("")); assertNotEquals(DeploymentSpec.empty, DeploymentSpec.fromXml("")); assertNotEquals(DeploymentSpec.empty, DeploymentSpec.fromXml("\n" + "")); } @Test public void emptyNotifications() { DeploymentSpec spec = DeploymentSpec.fromXml("\n" + " " + ""); assertEquals(Notifications.none(), spec.requireInstance("default").notifications()); } @Test public void someNotifications() { DeploymentSpec spec = DeploymentSpec.fromXml("\n" + " \n" + " \n" + " \n" + " \n" + " \n" + ""); assertEquals(ImmutableSet.of(author), spec.requireInstance("default").notifications().emailRolesFor(failing)); assertEquals(ImmutableSet.of(author), spec.requireInstance("default").notifications().emailRolesFor(failingCommit)); assertEquals(ImmutableSet.of("john@dev", "jane@dev"), spec.requireInstance("default").notifications().emailAddressesFor(failingCommit)); assertEquals(ImmutableSet.of("jane@dev"), spec.requireInstance("default").notifications().emailAddressesFor(failing)); } @Test public void customTesterFlavor() { DeploymentSpec spec = DeploymentSpec.fromXml("\n" + " \n" + " \n" + " \n" + " us-north-7\n" + " \n" + ""); assertEquals(Optional.of("d-1-4-20"), spec.requireInstance("default").steps().get(0).zones().get(0).testerFlavor()); assertEquals(Optional.empty(), spec.requireInstance("default").steps().get(1).zones().get(0).testerFlavor()); assertEquals(Optional.of("d-2-8-50"), spec.requireInstance("default").steps().get(2).zones().get(0).testerFlavor()); } @Test public void emptyEndpoints() { var spec = DeploymentSpec.fromXml(""); assertEquals(Collections.emptyList(), spec.requireInstance("default").endpoints()); } @Test public void someEndpoints() { var spec = DeploymentSpec.fromXml(""" us-east us-east us-east """); assertEquals( List.of("foo", "nalle", "default"), spec.requireInstance("default").endpoints().stream().map(Endpoint::endpointId).toList() ); assertEquals( List.of("bar", "frosk", "quux"), spec.requireInstance("default").endpoints().stream().map(Endpoint::containerId).toList() ); assertEquals(List.of(RegionName.from("us-east")), spec.requireInstance("default").endpoints().get(0).regions()); var zone = from(prod, RegionName.from("us-east")); assertEquals(ZoneEndpoint.defaultEndpoint, spec.zoneEndpoint(InstanceName.from("custom"), zone, ClusterSpec.Id.from("bax"))); assertEquals(ZoneEndpoint.defaultEndpoint, spec.zoneEndpoint(InstanceName.from("default"), defaultId(), ClusterSpec.Id.from("bax"))); assertEquals(ZoneEndpoint.defaultEndpoint, spec.zoneEndpoint(InstanceName.from("default"), zone, ClusterSpec.Id.from("bax"))); assertEquals(new ZoneEndpoint(false, true, List.of(new AllowedUrn(AccessType.awsPrivateLink, "barn"), new AllowedUrn(AccessType.gcpServiceConnect, "nine"))), spec.zoneEndpoint(InstanceName.from("default"), zone, ClusterSpec.Id.from("froz"))); } @Test public void endpointDefaultRegions() { var spec = DeploymentSpec.fromXml("" + "" + " " + " us-east" + " us-west" + " " + " " + " " + " us-east" + " " + " " + " " + " " + ""); 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)); } @Test public void productionSpecWithCloudAccount() { StringReader r = new StringReader( "" + " " + " us-east-1" + " us-west-1" + " " + "" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); DeploymentInstanceSpec instance = spec.requireInstance("default"); assertEquals(Map.of(AWS, CloudAccount.from("012345678912")), spec.cloudAccounts()); assertEquals(Map.of(AWS, CloudAccount.from("219876543210")), instance.cloudAccounts(prod, RegionName.from("us-east-1"))); assertEquals(Map.of(AWS, CloudAccount.from("012345678912")), instance.cloudAccounts(prod, RegionName.from("us-west-1"))); assertEquals(Map.of(AWS, CloudAccount.from("012345678912")), instance.cloudAccounts(Environment.staging, RegionName.defaultName())); r = new StringReader( "" + " " + " us-east-1" + " us-west-1" + " " + "" ); spec = DeploymentSpec.fromXml(r); assertEquals(Map.of(), spec.cloudAccounts()); assertEquals(Map.of(AWS, CloudAccount.from("219876543210")), spec.requireInstance("default").cloudAccounts(prod, RegionName.from("us-east-1"))); assertEquals(Map.of(), spec.requireInstance("default").cloudAccounts(prod, RegionName.from("us-west-1"))); } @Test public void productionSpecWithHostTTL() { String r = """ us-east-1 us-west-1 """; DeploymentSpec spec = DeploymentSpec.fromXml(r); assertEquals(Optional.of(Duration.ofDays(1)), spec.hostTTL()); DeploymentInstanceSpec instance = spec.requireInstance("default"); assertEquals(Optional.of(Duration.ofMinutes(1)), instance.hostTTL(prod, Optional.of(RegionName.from("us-east-1")))); assertEquals(Optional.of(Duration.ofDays(1)), instance.hostTTL(prod, Optional.of(RegionName.from("us-west-1")))); assertEquals(Optional.of(Duration.ofDays(1)), instance.hostTTL(test, Optional.empty())); r = """ us-east-1 us-west-1 """; spec = DeploymentSpec.fromXml(r); assertEquals(Optional.empty(), spec.hostTTL()); instance = spec.requireInstance("default"); assertEquals(Optional.of(Duration.ofMinutes(1)), instance.hostTTL(prod, Optional.of(RegionName.from("us-east-1")))); assertEquals(Optional.of(Duration.ofDays(1)), instance.hostTTL(prod, Optional.of(RegionName.from("us-west-1")))); assertEquals(Optional.empty(), instance.hostTTL(test, Optional.empty())); } private static Set endpointRegions(String endpointId, DeploymentSpec spec) { return spec.requireInstance("default").endpoints().stream() .filter(endpoint -> endpoint.endpointId().equals(endpointId)) .flatMap(endpoint -> endpoint.regions().stream()) .map(RegionName::value) .collect(Collectors.toSet()); } }