// 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.ImmutableList; 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.Flavor; import com.yahoo.config.provision.RegionName; import java.io.BufferedReader; import java.io.FileReader; import java.io.Reader; 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; /** * Specifies the environments and regions to which an application should be deployed. * This may be used both for inspection as part of an application model and to answer * queries about deployment from the command line. A main method is included for the latter usage. * * A deployment consists of a number of steps executed in the order listed in deployment xml, as * well as some additional settings. * * This is immutable. * * @author bratseth */ public class DeploymentSpec { /** The empty deployment spec, specifying no zones or rotation, and defaults for all settings */ public static final DeploymentSpec empty = new DeploymentSpec(Optional.empty(), UpgradePolicy.defaultPolicy, Optional.empty(), Collections.emptyList(), Collections.emptyList(), "", Optional.empty(), Optional.empty(), Notifications.none(), List.of()); private final Optional globalServiceId; private final UpgradePolicy upgradePolicy; private final Optional majorVersion; private final List changeBlockers; private final List steps; private final String xmlForm; private final Optional athenzDomain; private final Optional athenzService; private final Notifications notifications; private final List endpoints; public DeploymentSpec(Optional globalServiceId, UpgradePolicy upgradePolicy, Optional majorVersion, List changeBlockers, List steps, String xmlForm, Optional athenzDomain, Optional athenzService, Notifications notifications, List endpoints) { validateTotalDelay(steps); this.globalServiceId = globalServiceId; this.upgradePolicy = upgradePolicy; this.majorVersion = majorVersion; this.changeBlockers = changeBlockers; this.steps = ImmutableList.copyOf(completeSteps(new ArrayList<>(steps))); this.xmlForm = xmlForm; this.athenzDomain = athenzDomain; this.athenzService = athenzService; this.notifications = notifications; this.endpoints = ImmutableList.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 validateEndpoints(List endpoints, List 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(); for (var endpoint : endpoints) { if (endpoint.regions().isEmpty()) { var rebuiltEndpoint = endpoint.withRegions(productionRegions); rebuiltEndpointsList.add(rebuiltEndpoint); } else { rebuiltEndpointsList.add(endpoint); } } return ImmutableList.copyOf(rebuiltEndpointsList); } /** Throw an IllegalArgumentException if the total delay exceeds 24 hours */ private void validateTotalDelay(List 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 steps) { Set 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 steps, Optional globalServiceId, List 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 (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); } } } } private void ensureUnique(DeclaredZone zone, Set zones) { if ( ! zones.add(zone)) throw new IllegalArgumentException(zone + " is listed twice in deployment.xml"); } /** Adds missing required steps and reorders steps to a permissible order */ private static List completeSteps(List steps) { // 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)); } // 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)); } // Enforce order test, staging, prod DeclaredZone testStep = remove(Environment.test, steps); if (testStep != null) steps.add(0, testStep); 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 steps) { for (int i = 0; i < steps.size(); i++) { if (steps.get(i).deploysTo(environment)) return (DeclaredZone)steps.remove(i); } return null; } /** Returns the ID of the service to expose through global routing, if present */ public Optional globalServiceId() { return globalServiceId; } /** Returns the upgrade policy of this, which is defaultPolicy if none is specified */ public UpgradePolicy upgradePolicy() { return upgradePolicy; } /** Returns the major version this application is pinned to, or empty (default) to allow all major versions */ public Optional 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)); } /** 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)); } /** Returns time windows where upgrades are disallowed */ public List changeBlocker() { return changeBlockers; } /** Returns the deployment steps of this in the order they will be performed */ public List steps() { return steps; } /** Returns all the DeclaredZone deployment steps in the order they are declared */ public List zones() { return steps.stream() .flatMap(step -> step.zones().stream()) .collect(Collectors.toList()); } /** Returns the notification configuration */ public Notifications notifications() { return notifications; } /** Returns the rotations configuration */ public List endpoints() { return 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 */ public boolean includes(Environment environment, Optional region) { for (Step step : steps) if (step.deploysTo(environment, region)) return true; return false; } /** * Creates a deployment spec from XML. * * @throws IllegalArgumentException if the XML is invalid */ public static DeploymentSpec fromXml(Reader reader) { return new DeploymentSpecXmlReader().read(reader); } /** * Creates a deployment spec from XML. * * @throws IllegalArgumentException if the XML is invalid */ public static DeploymentSpec fromXml(String xmlForm) { return fromXml(xmlForm, true); } /** * Creates a deployment spec from XML. * * @throws IllegalArgumentException if the XML is invalid */ public static DeploymentSpec fromXml(String xmlForm, boolean validate) { return new DeploymentSpecXmlReader(validate).read(xmlForm); } public static String toMessageString(Throwable t) { StringBuilder b = new StringBuilder(); String lastMessage = null; String message; for (; t != null; t = t.getCause()) { message = t.getMessage(); if (message == null) continue; if (message.equals(lastMessage)) continue; if (b.length() > 0) { b.append(": "); } b.append(message); lastMessage = message; } return b.toString(); } /** Returns the athenz domain if configured */ public Optional athenzDomain() { return athenzDomain; } /** Returns the athenz service for environment/region if configured */ public Optional 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); } @Override public int hashCode() { return Objects.hash(globalServiceId, upgradePolicy, majorVersion, changeBlockers, steps, xmlForm, athenzDomain, athenzService, notifications); } /** This may be invoked by a continuous build */ public static void main(String[] args) { if (args.length != 2 && args.length != 3) { System.err.println("Usage: DeploymentSpec [file] [environment] [region]?" + "Returns 0 if the specified zone matches the deployment spec, 1 otherwise"); System.exit(1); } try (BufferedReader reader = new BufferedReader(new FileReader(args[0]))) { DeploymentSpec spec = DeploymentSpec.fromXml(reader); Environment environment = Environment.from(args[1]); Optional region = args.length == 3 ? Optional.of(RegionName.from(args[2])) : Optional.empty(); if (spec.includes(environment, region)) System.exit(0); else System.exit(1); } catch (Exception e) { System.err.println("Exception checking deployment spec: " + toMessageString(e)); System.exit(1); } } /** 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()); } /** Returns whether this step deploys to the given environment, and (if specified) region */ public abstract boolean deploysTo(Environment environment, Optional region); /** Returns the zones deployed to in this step */ public List zones() { return Collections.emptyList(); } } /** A deployment step which is to wait for some time before progressing to the next step */ public static class Delay extends Step { private final Duration duration; public Delay(Duration duration) { this.duration = duration; } public Duration duration() { return duration; } @Override public boolean deploysTo(Environment environment, Optional region) { return false; } } /** A deployment step which is to run deployment in a particular zone */ public static class DeclaredZone extends Step { private final Environment environment; private final Optional region; private final boolean active; private final Optional athenzService; private final Optional testerFlavor; public DeclaredZone(Environment environment) { this(environment, Optional.empty(), false); } public DeclaredZone(Environment environment, Optional region, boolean active) { this(environment, region, active, Optional.empty(), Optional.empty()); } public DeclaredZone(Environment environment, Optional region, boolean active, Optional athenzService) { this(environment, region, active, athenzService, Optional.empty()); } public DeclaredZone(Environment environment, Optional region, boolean active, Optional athenzService, Optional testerFlavor) { if (environment != Environment.prod && region.isPresent()) throw new IllegalArgumentException("Non-prod environments cannot specify a region"); if (environment == Environment.prod && region.isEmpty()) throw new IllegalArgumentException("Prod environments must be specified with a region"); this.environment = environment; this.region = region; this.active = active; this.athenzService = athenzService; this.testerFlavor = testerFlavor; } public Environment environment() { return environment; } /** The region name, or empty if not declared */ public Optional region() { return region; } /** Returns whether this zone should receive production traffic */ public boolean active() { return active; } public Optional testerFlavor() { return testerFlavor; } public Optional athenzService() { return athenzService; } @Override public List zones() { return Collections.singletonList(this); } @Override public boolean deploysTo(Environment environment, Optional region) { if (environment != this.environment) return false; if (region.isPresent() && ! region.equals(this.region)) return false; return true; } @Override public int hashCode() { return Objects.hash(environment, region); } @Override public boolean equals(Object o) { if (o == this) return true; if ( ! (o instanceof DeclaredZone)) return false; DeclaredZone other = (DeclaredZone)o; if (this.environment != other.environment) return false; if ( ! this.region.equals(other.region())) return false; return true; } @Override public String toString() { return environment + (region.map(regionName -> "." + regionName).orElse("")); } } /** A deployment step which is to run deployment to multiple zones in parallel */ public static class ParallelZones extends Step { private final List zones; public ParallelZones(List zones) { this.zones = ImmutableList.copyOf(zones); } @Override public List zones() { return this.zones; } @Override public boolean deploysTo(Environment environment, Optional region) { return zones.stream().anyMatch(zone -> zone.deploysTo(environment, region)); } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof ParallelZones)) return false; ParallelZones that = (ParallelZones) o; return Objects.equals(zones, that.zones); } @Override public int hashCode() { return Objects.hash(zones); } } /** Controls when this application will be upgraded to new Vespa versions */ public enum UpgradePolicy { /** Canary: Applications with this policy will upgrade before any other */ canary, /** Default: Will upgrade after all canary applications upgraded successfully. The default. */ defaultPolicy, /** Will upgrade after most default applications upgraded successfully */ conservative } /** A blocking of changes in a given time window */ public static class ChangeBlocker { private final boolean revision; private final boolean version; private final TimeWindow window; public ChangeBlocker(boolean revision, boolean version, TimeWindow window) { this.revision = revision; this.version = version; this.window = window; } public boolean blocksRevisions() { return revision; } public boolean blocksVersions() { return version; } public TimeWindow window() { return window; } } }