// 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.Environment;
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,
Collections.emptyList(),
Collections.emptyList(),
"",
Optional.empty(),
Optional.empty());
private final Optional globalServiceId;
private final UpgradePolicy upgradePolicy;
private final List changeBlockers;
private final List steps;
private final String xmlForm;
private final Optional athenzDomain;
private final Optional athenzService;
public DeploymentSpec(Optional globalServiceId, UpgradePolicy upgradePolicy,
List changeBlockers, List steps) {
this(globalServiceId, upgradePolicy, changeBlockers, steps, null, Optional.empty(), Optional.empty());
}
public DeploymentSpec(Optional globalServiceId, UpgradePolicy upgradePolicy,
List changeBlockers, List steps, String xmlForm,
Optional athenzDomain, Optional athenzService) {
validateTotalDelay(steps);
this.globalServiceId = globalServiceId;
this.upgradePolicy = upgradePolicy;
this.changeBlockers = changeBlockers;
this.steps = ImmutableList.copyOf(completeSteps(new ArrayList<>(steps)));
this.xmlForm = xmlForm;
this.athenzDomain = athenzDomain;
this.athenzService = athenzService;
validateZones(this.steps);
validateAthenz();
}
/** 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 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.isPresent()) {
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 configured, athenz service must be set implicitly or directly on all zones.
} else if(! athenzService.isPresent()) {
for (DeclaredZone zone : zones()) {
if(! zone.athenzService().isPresent()) {
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 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 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) {
return zones().stream()
.filter(zone -> zone.deploysTo(environment, Optional.of(region)))
.findFirst()
.map(DeclaredZone::athenzService)
.orElse(athenzService);
}
/** 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 Optional region;
private final boolean active;
private Optional athenzService;
public DeclaredZone(Environment environment) {
this(environment, Optional.empty(), false);
}
public DeclaredZone(Environment environment, Optional region, boolean active) {
this(environment, region, active, Optional.empty());
}
public DeclaredZone(Environment environment, Optional region, boolean active, Optional athenzService) {
if (environment != Environment.prod && region.isPresent())
throw new IllegalArgumentException("Non-prod environments cannot specify a region");
if (environment == Environment.prod && ! region.isPresent())
throw new IllegalArgumentException("Prod environments must be specified with a region");
this.environment = environment;
this.region = region;
this.active = active;
this.athenzService = athenzService;
}
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 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.isPresent() ? "." + region.get() : "");
}
}
/** 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; }
}
}