aboutsummaryrefslogtreecommitdiffstats
path: root/configserver/src/main/java/com/yahoo/vespa/config/server/http/v1/RoutingStatusApiHandler.java
blob: 369f91ec8763b641cecd4b5a430e4090d0e8c025 (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
// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.config.server.http.v1;

import com.yahoo.component.annotation.Inject;
import com.yahoo.config.provision.ApplicationId;
import com.yahoo.jdisc.http.HttpRequest;
import com.yahoo.path.Path;
import com.yahoo.restapi.RestApi;
import com.yahoo.restapi.RestApiException;
import com.yahoo.restapi.RestApiRequestHandler;
import com.yahoo.restapi.SlimeJsonResponse;
import com.yahoo.slime.Cursor;
import com.yahoo.slime.Slime;
import com.yahoo.slime.SlimeUtils;
import com.yahoo.vespa.curator.Curator;
import com.yahoo.vespa.curator.transaction.CuratorOperations;
import com.yahoo.vespa.curator.transaction.CuratorTransaction;
import com.yahoo.yolean.Exceptions;

import java.time.Clock;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;

/**
 * This implements the /routing/v1/status REST API on the config server, providing explicit control over the routing
 * status of a deployment or zone (all deployments). The routing status manipulated by this is only respected by the
 * shared routing layer.
 *
 * @author bjorncs
 * @author mpolden
 */
public class RoutingStatusApiHandler extends RestApiRequestHandler<RoutingStatusApiHandler> {

    private static final Logger log = Logger.getLogger(RoutingStatusApiHandler.class.getName());

    private static final Path ROUTING_ROOT = Path.fromString("/routing/v1/");
    private static final Path DEPLOYMENT_STATUS_ROOT = ROUTING_ROOT.append("status");
    private static final Path ZONE_STATUS = ROUTING_ROOT.append("zone-inactive");

    private final Curator curator;
    private final Clock clock;

    @Inject
    public RoutingStatusApiHandler(Context context, Curator curator) {
        this(context, curator, Clock.systemUTC());
    }

    RoutingStatusApiHandler(Context context, Curator curator, Clock clock) {
        super(context, RoutingStatusApiHandler::createRestApiDefinition);
        this.curator = Objects.requireNonNull(curator);
        this.clock = Objects.requireNonNull(clock);

        curator.create(DEPLOYMENT_STATUS_ROOT);
    }

    private static RestApi createRestApiDefinition(RoutingStatusApiHandler self) {
        return RestApi.builder()
                // TODO(mpolden): Remove this route when clients have migrated to v2
                .addRoute(RestApi.route("/routing/v1/status")
                    .get(self::listInactiveDeployments))
                .addRoute(RestApi.route("/routing/v1/status/zone")
                    .get(self::zoneStatus)
                    .put(self::changeZoneStatus)
                    .delete(self::changeZoneStatus))
                .addRoute(RestApi.route("/routing/v1/status/{upstreamName}")
                    .get(self::getDeploymentStatus)
                    .put(self::changeDeploymentStatus))
                .addRoute(RestApi.route("/routing/v2/status")
                                 .get(self::getDeploymentStatusV2))
                .build();
    }

    /* Get inactive deployments and zone status */
    private SlimeJsonResponse getDeploymentStatusV2(RestApi.RequestContext context) {
        Slime slime = new Slime();
        Cursor root = slime.setObject();
        Cursor inactiveDeploymentsArray = root.setArray("inactiveDeployments");
        curator.getChildren(DEPLOYMENT_STATUS_ROOT).stream()
               .filter(upstreamName -> deploymentStatus(upstreamName).status() == RoutingStatus.out)
               .sorted()
               .forEach(upstreamName -> {
                   Cursor deploymentObject = inactiveDeploymentsArray.addObject();
                   deploymentObject.setString("upstreamName", upstreamName);
               });
        root.setBool("zoneActive", zoneStatus() == RoutingStatus.in);
        return new SlimeJsonResponse(slime);
    }

    /** Get upstream of all deployments with status OUT */
    private SlimeJsonResponse listInactiveDeployments(RestApi.RequestContext context) {
        List<String> inactiveDeployments = curator.getChildren(DEPLOYMENT_STATUS_ROOT).stream()
                                                  .filter(upstreamName -> deploymentStatus(upstreamName).status() == RoutingStatus.out)
                                                  .sorted()
                                                  .toList();
        Slime slime = new Slime();
        Cursor rootArray = slime.setArray();
        inactiveDeployments.forEach(rootArray::addString);
        return new SlimeJsonResponse(slime);
    }

    /** Get the routing status of a deployment */
    private SlimeJsonResponse getDeploymentStatus(RestApi.RequestContext context) {
        String upstreamName = upstreamName(context);
        DeploymentRoutingStatus deploymentRoutingStatus = deploymentStatus(upstreamName);
        // If the entire zone is out, we always return OUT regardless of the actual routing status
        if (zoneStatus() == RoutingStatus.out) {
            String reason = String.format("Rotation is OUT because the zone is OUT (actual deployment status is %s)",
                                          deploymentRoutingStatus.status().name().toUpperCase(Locale.ENGLISH));
            deploymentRoutingStatus = new DeploymentRoutingStatus(RoutingStatus.out, "operator", reason,
                                                                  clock.instant());
        }
        return new SlimeJsonResponse(toSlime(deploymentRoutingStatus));
    }

    /** Change routing status of a deployment */
    private SlimeJsonResponse changeDeploymentStatus(RestApi.RequestContext context) {
        Set<String> upstreamNames = upstreamNames(context);
        ApplicationId instance = instance(context);
        RestApi.RequestContext.RequestContent requestContent = context.requestContentOrThrow();
        Slime requestBody = Exceptions.uncheck(() -> SlimeUtils.jsonToSlime(requestContent.content().readAllBytes()));
        DeploymentRoutingStatus wantedStatus = deploymentRoutingStatusFromSlime(requestBody, clock.instant());
        List<DeploymentRoutingStatus> currentStatuses = upstreamNames.stream()
                                                                     .map(this::deploymentStatus)
                                                                     .toList();
        DeploymentRoutingStatus currentStatus = currentStatuses.get(0);
        log.log(Level.INFO, "Changing routing status of " + instance + " from " +
                            currentStatus.status() + " to " + wantedStatus.status());
        boolean needsChange = currentStatuses.stream().anyMatch(status -> status.status() != wantedStatus.status());
        if (needsChange) {
            changeStatus(upstreamNames, wantedStatus);
        }
        return new SlimeJsonResponse(toSlime(wantedStatus));
    }

    /** Change routing status of a zone */
    private SlimeJsonResponse changeZoneStatus(RestApi.RequestContext context) {
        boolean in = context.request().getMethod() == HttpRequest.Method.DELETE;
        log.log(Level.INFO, "Changing routing status of zone from " + zoneStatus() + " to " +
                            (in ? RoutingStatus.in : RoutingStatus.out));
        if (in) {
            curator.delete(ZONE_STATUS);
            return new SlimeJsonResponse(toSlime(RoutingStatus.in));
        } else {
            curator.create(ZONE_STATUS);
            return new SlimeJsonResponse(toSlime(RoutingStatus.out));
        }
    }

    /** Read the status for zone */
    private SlimeJsonResponse zoneStatus(RestApi.RequestContext context) {
        return new SlimeJsonResponse(toSlime(zoneStatus()));
    }

    /** Change the status of one or more upstream names */
    private void changeStatus(Set<String> upstreamNames, DeploymentRoutingStatus newStatus) {
        CuratorTransaction transaction = new CuratorTransaction(curator);
        for (var upstreamName : upstreamNames) {
            Path path = deploymentStatusPath(upstreamName);
            if (curator.exists(path)) {
                transaction.add(CuratorOperations.delete(path.getAbsolute()));
            }
            transaction.add(CuratorOperations.create(path.getAbsolute(), toJsonBytes(newStatus)));
        }
        transaction.commit();
    }

    /** Read the status for a deployment */
    private DeploymentRoutingStatus deploymentStatus(String upstreamName) {
        Instant changedAt = clock.instant();
        Path path = deploymentStatusPath(upstreamName);
        Optional<byte[]> data = curator.getData(path);
        if (data.isEmpty()) {
            return new DeploymentRoutingStatus(RoutingStatus.in, "", "", changedAt);
        }
        String agent = "";
        String reason = "";
        RoutingStatus status = RoutingStatus.out;
        if (data.get().length > 0) { // Compatibility with old format, where no data is stored
            Slime slime = SlimeUtils.jsonToSlime(data.get());
            Cursor root = slime.get();
            status = asRoutingStatus(root.field("status").asString());
            agent = root.field("agent").asString();
            reason = root.field("cause").asString();
            changedAt = Instant.ofEpochSecond(root.field("lastUpdate").asLong());
        }
        return new DeploymentRoutingStatus(status, agent, reason, changedAt);
    }

    private RoutingStatus zoneStatus() {
        return curator.exists(ZONE_STATUS) ? RoutingStatus.out : RoutingStatus.in;
    }

    protected Path deploymentStatusPath(String upstreamName) {
        return DEPLOYMENT_STATUS_ROOT.append(upstreamName);
    }

    private static String upstreamName(RestApi.RequestContext context) {
        return upstreamNames(context).iterator().next();
    }

    private static Set<String> upstreamNames(RestApi.RequestContext context) {
        Set<String> upstreamNames = Arrays.stream(context.pathParameters().getStringOrThrow("upstreamName")
                                                         .split(","))
                                          .collect(Collectors.toSet());
        if (upstreamNames.isEmpty()) {
            throw new RestApiException.BadRequest("At least one upstream name must be specified");
        }
        for (var upstreamName : upstreamNames) {
            if (upstreamName.contains(" ")) {
                throw new RestApiException.BadRequest("Invalid upstream name: '" + upstreamName + "'");
            }
        }
        return upstreamNames;
    }

    private static ApplicationId instance(RestApi.RequestContext context) {
        return context.queryParameters().getString("application")
                      .map(ApplicationId::fromSerializedForm)
                      .orElseThrow(() -> new RestApiException.BadRequest("Missing application parameter"));
    }

    private byte[] toJsonBytes(DeploymentRoutingStatus status) {
        return Exceptions.uncheck(() -> SlimeUtils.toJsonBytes(toSlime(status)));
    }

    private Slime toSlime(DeploymentRoutingStatus status) {
        Slime slime = new Slime();
        Cursor root = slime.setObject();
        root.setString("status", asString(status.status()));
        root.setString("cause", status.reason());
        root.setString("agent", status.agent());
        root.setLong("lastUpdate", status.changedAt().getEpochSecond());
        return slime;
    }

    private static Slime toSlime(RoutingStatus status) {
        Slime slime = new Slime();
        Cursor root = slime.setObject();
        root.setString("status", asString(status));
        return slime;
    }

    private static RoutingStatus asRoutingStatus(String s) {
        switch (s) {
            case "IN": return RoutingStatus.in;
            case "OUT": return RoutingStatus.out;
        }
        throw new IllegalArgumentException("Unknown status: '" + s + "'");
    }

    private static String asString(RoutingStatus status) {
        switch (status) {
            case in: return "IN";
            case out: return "OUT";
        }
        throw new IllegalArgumentException("Unknown status: " + status);
    }

    private static DeploymentRoutingStatus deploymentRoutingStatusFromSlime(Slime slime, Instant changedAt) {
        Cursor root = slime.get();
        return new DeploymentRoutingStatus(asRoutingStatus(root.field("status").asString()),
                                           root.field("agent").asString(),
                                           root.field("cause").asString(),
                                           changedAt);
    }

    private static class DeploymentRoutingStatus {

        private final RoutingStatus status;
        private final String agent;
        private final String reason;
        private final Instant changedAt;

        public DeploymentRoutingStatus(RoutingStatus status, String agent, String reason, Instant changedAt) {
            this.status = Objects.requireNonNull(status);
            this.agent = Objects.requireNonNull(agent);
            this.reason = Objects.requireNonNull(reason);
            this.changedAt = Objects.requireNonNull(changedAt);
        }

        public RoutingStatus status() {
            return status;
        }

        public String agent() {
            return agent;
        }

        public String reason() {
            return reason;
        }

        public Instant changedAt() {
            return changedAt;
        }

    }

    private enum RoutingStatus {
        in, out
    }

}