aboutsummaryrefslogtreecommitdiffstats
path: root/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentInstanceSpec.java
blob: c04874f2b16d5825024d670bd8b0e05480cd3d3f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
// Copyright Vespa.ai. 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.AthenzService;
import com.yahoo.config.provision.CloudAccount;
import com.yahoo.config.provision.CloudName;
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.Tags;
import com.yahoo.config.provision.ZoneEndpoint;
import com.yahoo.config.provision.zone.ZoneId;

import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static ai.vespa.validation.Validation.require;
import static ai.vespa.validation.Validation.requireAtLeast;
import static ai.vespa.validation.Validation.requireInRange;
import static com.yahoo.config.application.api.DeploymentSpec.RevisionChange.whenClear;
import static com.yahoo.config.application.api.DeploymentSpec.RevisionTarget.next;
import static com.yahoo.config.application.api.DeploymentSpec.illegal;
import static com.yahoo.config.provision.Environment.prod;

/**
 * The deployment spec for an application instance
 *
 * @author bratseth
 */
public class DeploymentInstanceSpec extends DeploymentSpec.Steps {

    /** The maximum number of consecutive days Vespa upgrades are allowed to be blocked */
    private static final int maxUpgradeBlockingDays = 21;

    /** The name of the instance this step deploys */
    private final InstanceName name;

    private final Tags tags;
    private final DeploymentSpec.UpgradePolicy upgradePolicy;
    private final DeploymentSpec.RevisionTarget revisionTarget;
    private final DeploymentSpec.RevisionChange revisionChange;
    private final DeploymentSpec.UpgradeRollout upgradeRollout;
    private final int minRisk;
    private final int maxRisk;
    private final int maxIdleHours;
    private final List<DeploymentSpec.ChangeBlocker> changeBlockers;
    private final Optional<AthenzService> athenzService;
    private final Map<CloudName, CloudAccount> cloudAccounts;
    private final Optional<Duration> hostTTL;
    private final Notifications notifications;
    private final List<Endpoint> endpoints;
    private final Map<ClusterSpec.Id, Map<ZoneId, ZoneEndpoint>> zoneEndpoints;
    private final Bcp bcp;

    public DeploymentInstanceSpec(InstanceName name,
                                  Tags tags,
                                  List<DeploymentSpec.Step> steps,
                                  DeploymentSpec.UpgradePolicy upgradePolicy,
                                  DeploymentSpec.RevisionTarget revisionTarget,
                                  DeploymentSpec.RevisionChange revisionChange,
                                  DeploymentSpec.UpgradeRollout upgradeRollout,
                                  int minRisk, int maxRisk, int maxIdleHours,
                                  List<DeploymentSpec.ChangeBlocker> changeBlockers,
                                  Optional<AthenzService> athenzService,
                                  Map<CloudName, CloudAccount> cloudAccounts,
                                  Optional<Duration> hostTTL,
                                  Notifications notifications,
                                  List<Endpoint> endpoints,
                                  Map<ClusterSpec.Id, Map<ZoneId, ZoneEndpoint>> zoneEndpoints,
                                  Bcp bcp,
                                  Instant now) {
        super(steps);
        this.name = Objects.requireNonNull(name);
        this.tags = Objects.requireNonNull(tags);
        this.upgradePolicy = Objects.requireNonNull(upgradePolicy);
        Objects.requireNonNull(revisionTarget);
        Objects.requireNonNull(revisionChange);
        this.revisionTarget = require(maxRisk == 0 || revisionTarget == next, revisionTarget,
                                      "revision-target must be 'next' when max-risk is specified");
        this.revisionChange = require(maxRisk == 0 || revisionChange == whenClear, revisionChange,
                                      "revision-change must be 'when-clear' when max-risk is specified");
        this.upgradeRollout = Objects.requireNonNull(upgradeRollout);
        this.minRisk = requireAtLeast(minRisk, "minimum risk score", 0);
        this.maxRisk = require(maxRisk >= minRisk, maxRisk, "maximum risk cannot be less than minimum risk score");
        this.maxIdleHours = requireInRange(maxIdleHours, "maximum idle hours", 0, 168);
        this.changeBlockers = Objects.requireNonNull(changeBlockers);
        this.athenzService = Objects.requireNonNull(athenzService);
        this.cloudAccounts = Map.copyOf(cloudAccounts);
        this.hostTTL = Objects.requireNonNull(hostTTL);
        this.notifications = Objects.requireNonNull(notifications);
        this.endpoints = List.copyOf(Objects.requireNonNull(endpoints));
        Map<ClusterSpec.Id, Map<ZoneId, ZoneEndpoint>> zoneEndpointsCopy =  new HashMap<>();
        for (var entry : zoneEndpoints.entrySet()) zoneEndpointsCopy.put(entry.getKey(), Collections.unmodifiableMap(new HashMap<>(entry.getValue())));
        this.zoneEndpoints = Collections.unmodifiableMap(zoneEndpointsCopy);
        this.bcp = Objects.requireNonNull(bcp);
        validateZones(new HashSet<>(), new HashSet<>(), this);
        validateEndpoints(this.endpoints);
        validateChangeBlockers(changeBlockers, now);
        validateBcp(bcp);
        hostTTL.filter(Duration::isNegative).ifPresent(ttl -> illegal("Host TTL cannot be negative"));
    }

    public InstanceName name() { return name; }

    public Tags tags() { return tags; }

    /**
     * Throws an IllegalArgumentException if any production deployment or test is declared multiple times,
     * or if any production test is declared not after its corresponding deployment.
     *
     * @param deployments previously seen deployments
     * @param tests previously seen tests
     * @param step step whose members to validate
     */
    private static void validateZones(Set<RegionName> deployments, Set<RegionName> tests, DeploymentSpec.Step step) {
        if ( ! step.steps().isEmpty()) {
            Set<RegionName> oldDeployments = Set.copyOf(deployments);
            for (DeploymentSpec.Step nested : step.steps()) {
                Set<RegionName> seenDeployments = new HashSet<>(step.isOrdered() ? deployments : oldDeployments);
                validateZones(seenDeployments, tests, nested);
                deployments.addAll(seenDeployments);
            }
        }
        else if (step.concerns(Environment.prod)) {
            if (step.isTest()) {
                RegionName region = ((DeploymentSpec.DeclaredTest) step).region();
                if ( ! deployments.contains(region))
                    throw new IllegalArgumentException("tests for prod." + region + " must be after the corresponding deployment in deployment.xml");
                if ( ! tests.add(region))
                    throw new IllegalArgumentException("tests for prod." + region + " are listed twice in deployment.xml");
            }
            else {
                RegionName region = ((DeploymentSpec.DeclaredZone) step).region().get();
                if ( ! deployments.add(region))
                    throw new IllegalArgumentException("prod." + region + " is listed twice in deployment.xml");
            }
        }
    }

    /** Throw an IllegalArgumentException if an endpoint refers to a region that is not declared in 'prod' */
    private void validateEndpoints(List<Endpoint> endpoints) {
        var regions = prodRegions();
        for (var endpoint : endpoints){
            for (var endpointRegion : endpoint.regions()) {
                if (! regions.contains(endpointRegion)) {
                    throw new IllegalArgumentException("Region used in endpoint that is not declared in 'prod': " + endpointRegion);
                }
            }
        }
    }

    /** Validates the given BCP instance (which is owned by this, or if none, a default) against this instance. */
    void validateBcp(Bcp bcp) {
        if (bcp.isEmpty()) return;
        if ( ! prodRegions().equals(bcp.regions()))
            throw new IllegalArgumentException("BCP and deployment mismatch in " + this + ": " +
                                               "A <bcp> element must place all deployed production regions in " +
                                               "at least one group, and declare no extra regions. " +
                                               "Deployed regions: " + prodRegions() +
                                               ". BCP regions: " + bcp.regions());
}
    /** Returns the production regions the steps of this specifies a deployment to. */
    private Set<RegionName> prodRegions() {
        return steps().stream()
                      .flatMap(s -> s.zones().stream())
                      .filter(zone -> zone.environment().isProduction())
                      .flatMap(z -> z.region().stream())
                      .collect(Collectors.toSet());
     }

    private void validateChangeBlockers(List<DeploymentSpec.ChangeBlocker> changeBlockers, Instant now) {
        // Find all possible dates an upgrade block window can start
        Stream<Instant> blockingFrom = changeBlockers.stream()
                                                     .filter(blocker -> blocker.blocksVersions())
                                                     .map(blocker -> blocker.window())
                                                     .map(window -> window.dateRange().start()
                                                                          .map(date -> date.atStartOfDay(window.zone())
                                                                                           .toInstant())
                                                                          .orElse(now))
                                                     .distinct();
        if (!blockingFrom.allMatch(this::canUpgradeWithinDeadline)) {
            throw new IllegalArgumentException("Cannot block Vespa upgrades for longer than " +
                                               maxUpgradeBlockingDays + " consecutive days");
        }
    }

    /** Returns whether this allows upgrade within deadline, relative to given instant */
    private boolean canUpgradeWithinDeadline(Instant instant) {
        instant = instant.truncatedTo(ChronoUnit.HOURS);
        Duration step = Duration.ofHours(1);
        Duration max = Duration.ofDays(maxUpgradeBlockingDays);
        for (Instant current = instant; ! canUpgradeAt(current); current = current.plus(step)) {
            Duration blocked = Duration.between(instant, current);
            if (blocked.compareTo(max) > 0) {
                return false;
            }
        }
        return true;
    }

    /** Returns the upgrade policy of this, which is {@link DeploymentSpec.UpgradePolicy#defaultPolicy} by default */
    public DeploymentSpec.UpgradePolicy upgradePolicy() { return upgradePolicy; }

    /** Returns the revision target choice of this, which is {@link DeploymentSpec.RevisionTarget#latest} by default */
    public DeploymentSpec.RevisionTarget revisionTarget() { return revisionTarget; }

    /** Returns the revision change strategy of this, which is {@link DeploymentSpec.RevisionChange#whenFailing} by default */
    public DeploymentSpec.RevisionChange revisionChange() { return revisionChange; }

    /** Returns the upgrade rollout strategy of this, which is {@link DeploymentSpec.UpgradeRollout#separate} by default */
    public DeploymentSpec.UpgradeRollout upgradeRollout() { return upgradeRollout; }

    /** Minimum cumulative, enqueued risk required for a new revision to roll out to this instance. 0 by default. */
    public int minRisk() { return minRisk; }

    /** Maximum cumulative risk that will automatically roll out to this instance, as long as this is possible. 0 by default. */
    public int maxRisk() { return maxRisk; }

    /* Maximum number of hours to wait for enqueued risk to reach the minimum, before rolling out whatever revisions are enqueued. 8 by default. */
    public int maxIdleHours() { return maxIdleHours; }

    /** Returns time windows where upgrades are disallowed for these instances */
    public List<DeploymentSpec.ChangeBlocker> changeBlocker() { return changeBlockers; }

    // TODO(mpolden): Remove after Vespa < 8.203 is no longer in use
    public Optional<String> globalServiceId() { return Optional.empty(); }

    /** 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 the athenz service for environment/region if configured, defaulting to that of the instance */
    public Optional<AthenzService> athenzService(Environment environment, RegionName region) {
        return zones().stream()
                      .filter(zone -> zone.concerns(environment, Optional.of(region)))
                      .findFirst()
                      .flatMap(DeploymentSpec.DeclaredZone::athenzService)
                      .or(() -> athenzService);
    }

    /** Returns the cloud accounts to use for given environment and region, if any */
    public Map<CloudName, CloudAccount> cloudAccounts(Environment environment, RegionName region) {
        return zones().stream()
                      .filter(zone -> zone.concerns(environment, Optional.of(region)))
                      .findFirst()
                      .map(DeploymentSpec.DeclaredZone::cloudAccounts)
                      .orElse(cloudAccounts);
    }

    /** Returns the host TTL to use for given environment and region, if any */
    public Optional<Duration> hostTTL(Environment environment, Optional<RegionName> region) {
        return zones().stream()
                      .filter(zone -> zone.concerns(environment, region))
                      .findFirst()
                      .flatMap(DeploymentSpec.DeclaredZone::hostTTL)
                      .or(() -> hostTTL);
    }

    /** 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 the BCP spec of this instance, or BcpSpec.empty() if none. */
    public Bcp bcp() { return bcp; }

    /** Returns whether this instance deploys to the given zone, either implicitly or explicitly */
    public boolean deploysTo(Environment environment, RegionName region) {
        return zones().stream().anyMatch(zone -> zone.concerns(environment, Optional.of(region)));
    }

    /** Returns the zone endpoint specified for the given region, or empty. */
    Optional<ZoneEndpoint> zoneEndpoint(ZoneId zone, ClusterSpec.Id cluster) {
        return Optional.ofNullable(zoneEndpoints.get(cluster))
                       .filter(__ -> deploysTo(zone.environment(), zone.region()))
                       .map(zoneEndpoints -> zoneEndpoints.get(zoneEndpoints.containsKey(zone) ? zone : null));
    }

    /** Returns the zone endpoint data for this instance. */
    Map<ClusterSpec.Id, Map<ZoneId, ZoneEndpoint>> zoneEndpoints() {
        return zoneEndpoints;
    }

    /** The zone endpoints in the given zone, possibly default values. */
    public Map<ClusterSpec.Id, ZoneEndpoint> zoneEndpoints(ZoneId zone) {
        return zoneEndpoints.keySet().stream()
                            .collect(Collectors.toMap(cluster -> cluster,
                                                      cluster -> zoneEndpoint(zone, cluster).orElse(ZoneEndpoint.defaultEndpoint)));
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        DeploymentInstanceSpec other = (DeploymentInstanceSpec) o;
        return upgradePolicy == other.upgradePolicy &&
               revisionTarget == other.revisionTarget &&
               upgradeRollout == other.upgradeRollout &&
               changeBlockers.equals(other.changeBlockers) &&
               steps().equals(other.steps()) &&
               athenzService.equals(other.athenzService) &&
               notifications.equals(other.notifications) &&
               endpoints.equals(other.endpoints) &&
               zoneEndpoints.equals(other.zoneEndpoints) &&
               bcp.equals(other.bcp) &&
               tags.equals(other.tags);
    }

    @Override
    public int hashCode() {
        return Objects.hash(upgradePolicy, revisionTarget, upgradeRollout, changeBlockers, steps(), athenzService, notifications, endpoints, zoneEndpoints, bcp, tags);
    }

    int deployableHashCode() {
        List<DeploymentSpec.DeclaredZone> zones = zones().stream().filter(zone -> zone.concerns(prod)).toList();
        Object[] toHash = new Object[zones.size() + 7];
        int i = 0;
        toHash[i++] = name;
        toHash[i++] = endpoints;
        toHash[i++] = zoneEndpoints;
        toHash[i++] = tags;
        toHash[i++] = bcp;
        toHash[i++] = cloudAccounts;
        for (DeploymentSpec.DeclaredZone zone : zones)
            toHash[i++] = Objects.hash(zone, zone.athenzService(), zone.cloudAccounts());

        return Arrays.hashCode(toHash);
    }

    @Override
    public String toString() {
        return "instance '" + name + "'";
    }

}