aboutsummaryrefslogtreecommitdiffstats
path: root/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi
diff options
context:
space:
mode:
Diffstat (limited to 'controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ErrorResponse.java66
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/MessageResponse.java31
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Path.java109
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ResourceResponse.java42
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/RootHandler.java96
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/SlimeJsonResponse.java38
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/StringResponse.java26
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Uri.java64
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java1065
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationChefEnvironment.java43
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java164
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/DeployAuthorizer.java117
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/EmptyJsonResponse.java25
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/JacksonJsonResponse.java31
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/MultipartParser.java72
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ServiceApiResponse.java191
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java84
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/JobsResponse.java46
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/DeploymentApiHandler.java122
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlHeaders.java26
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlRequestFilter.java68
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlResponseFilter.java55
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/DummyFilter.java16
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/NTokenRequestFilter.java33
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SetBouncerPassthruHeaderFilter.java27
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UnauthenticatedUserPrincipal.java44
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UserIdRequestFilter.java23
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/CreateSecurityContextFilter.java50
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/PropagateSecurityContextFilter.java31
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/package-info.java10
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/screwdriver/ScrewdriverApiHandler.java168
31 files changed, 2983 insertions, 0 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ErrorResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ErrorResponse.java
new file mode 100644
index 00000000000..a9643e21c00
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ErrorResponse.java
@@ -0,0 +1,66 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi;
+
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServerException;
+import com.yahoo.yolean.Exceptions;
+
+import static com.yahoo.jdisc.Response.Status.BAD_REQUEST;
+import static com.yahoo.jdisc.Response.Status.FORBIDDEN;
+import static com.yahoo.jdisc.Response.Status.INTERNAL_SERVER_ERROR;
+import static com.yahoo.jdisc.Response.Status.METHOD_NOT_ALLOWED;
+import static com.yahoo.jdisc.Response.Status.NOT_FOUND;
+
+/**
+ * A HTTP JSON response containing an error code and a message
+ *
+ * @author bratseth
+ */
+public class ErrorResponse extends SlimeJsonResponse {
+
+ public enum errorCodes {
+ NOT_FOUND,
+ BAD_REQUEST,
+ FORBIDDEN,
+ METHOD_NOT_ALLOWED,
+ INTERNAL_SERVER_ERROR
+ }
+
+ public ErrorResponse(int statusCode, String errorType, String message) {
+ super(statusCode, asSlimeMessage(errorType, message));
+ }
+
+ private static Slime asSlimeMessage(String errorType, String message) {
+ Slime slime = new Slime();
+ Cursor root = slime.setObject();
+ root.setString("error-code", errorType);
+ root.setString("message", message);
+ return slime;
+ }
+
+ public static ErrorResponse notFoundError(String message) {
+ return new ErrorResponse(NOT_FOUND, errorCodes.NOT_FOUND.name(), message);
+ }
+
+ public static ErrorResponse internalServerError(String message) {
+ return new ErrorResponse(INTERNAL_SERVER_ERROR, errorCodes.INTERNAL_SERVER_ERROR.name(), message);
+ }
+
+ public static ErrorResponse badRequest(String message) {
+ return new ErrorResponse(BAD_REQUEST, errorCodes.BAD_REQUEST.name(), message);
+ }
+
+ public static ErrorResponse forbidden(String message) {
+ return new ErrorResponse(FORBIDDEN, errorCodes.FORBIDDEN.name(), message);
+ }
+
+ public static ErrorResponse methodNotAllowed(String message) {
+ return new ErrorResponse(METHOD_NOT_ALLOWED, errorCodes.METHOD_NOT_ALLOWED.name(), message);
+ }
+
+ public static ErrorResponse from(ConfigServerException e) {
+ return new ErrorResponse(BAD_REQUEST, e.getErrorCode().name(), Exceptions.toMessageString(e));
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/MessageResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/MessageResponse.java
new file mode 100644
index 00000000000..8b2f0e9f09d
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/MessageResponse.java
@@ -0,0 +1,31 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi;
+
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.JsonFormat;
+import com.yahoo.slime.Slime;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author bratseth
+ */
+public class MessageResponse extends HttpResponse {
+
+ private final Slime slime = new Slime();
+
+ public MessageResponse(String message) {
+ super(200);
+ slime.setObject().setString("message", message);
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ new JsonFormat(true).encode(stream, slime);
+ }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Path.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Path.java
new file mode 100644
index 00000000000..c8c027d91c9
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Path.java
@@ -0,0 +1,109 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * A path which is able to match strings containing bracketed placeholders and return the
+ * values given at the placeholders.
+ *
+ * E.g a path /a/1/bar/fuz
+ * will match /a/{foo}/bar/{b}
+ * and return foo=1 and b=fuz
+ *
+ * Only full path elements may be placeholders, i.e /a{bar} is not interpreted as one.
+ *
+ * If the path spec ends with /{*}, it will match urls with any rest path.
+ * The rest path (not including the trailing slash) will be available as getRest().
+ *
+ * Note that for convenience in common use this has state which is changes as a side effect of each matches
+ * invocation. It is therefore for single thread use.
+ *
+ * @author bratseth
+ */
+public class Path {
+
+ // This path
+ private final String pathString;
+ private final String[] elements;
+
+ // Info about the last match
+ private final Map<String, String> values = new HashMap<>();
+ private String rest = "";
+
+ public Path(String path) {
+ this.pathString = path;
+ this.elements = path.split("/");
+ }
+
+ /**
+ * Returns whether this path matches the given template string.
+ * If the given template has placeholders, their values (accessible by get) are reset by calling this,
+ * whether or not the path matches the given template.
+ *
+ * This will NOT match empty path elements.
+ *
+ * @param pathSpec the path string to match to this
+ * @return true if the string matches, false otherwise
+ */
+ public boolean matches(String pathSpec) {
+ values.clear();
+ String[] specElements = pathSpec.split("/");
+ boolean matchPrefix = false;
+ if (specElements[specElements.length-1].equals("{*}")) {
+ matchPrefix = true;
+ specElements = Arrays.copyOf(specElements, specElements.length-1);
+ }
+
+ if (matchPrefix) {
+ if (this.elements.length < specElements.length) return false;
+ }
+ else { // match exact
+ if (this.elements.length != specElements.length) return false;
+ }
+
+ for (int i = 0; i < specElements.length; i++) {
+ if (specElements[i].startsWith("{") && specElements[i].endsWith("}")) // placeholder
+ values.put(specElements[i].substring(1, specElements[i].length()-1), elements[i]);
+ else if ( ! specElements[i].equals(this.elements[i]))
+ return false;
+ }
+
+ if (matchPrefix) {
+ StringBuilder rest = new StringBuilder();
+ for (int i = specElements.length; i < this.elements.length; i++)
+ rest.append(elements[i]).append("/");
+ if ( ! pathString.endsWith("/"))
+ rest.setLength(rest.length() -1);
+ this.rest = rest.toString();
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the value of the given template variable in the last path matched, or null
+ * if the previous matches call returned false or if this has not matched anything yet.
+ */
+ public String get(String placeholder) {
+ return values.get(placeholder);
+ }
+
+ /**
+ * Returns the rest of the last matched path.
+ * This is always the empty string (never null) unless the path spec ends with {*}
+ */
+ public String getRest() { return rest; }
+
+ /** Returns this path as a string */
+ public String asString() { return pathString; }
+
+ @Override
+ public String toString() {
+ return "path '" + Arrays.stream(elements).collect(Collectors.joining("/")) + "'";
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ResourceResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ResourceResponse.java
new file mode 100644
index 00000000000..550b47d8280
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/ResourceResponse.java
@@ -0,0 +1,42 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi;
+
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.JsonFormat;
+import com.yahoo.slime.Slime;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Returns a response containing an array of links to sub-resources
+ *
+ * @author bratseth
+ */
+public class ResourceResponse extends HttpResponse {
+
+ private final Slime slime = new Slime();
+
+ public ResourceResponse(HttpRequest request, String ... subResources) {
+ super(200);
+ Cursor resourceArray = slime.setObject().setArray("resources");
+ for (String subResource : subResources) {
+ Cursor resourceEntry = resourceArray.addObject();
+ resourceEntry.setString("url", new Uri(request.getUri())
+ .append(subResource)
+ .withTrailingSlash()
+ .toString());
+ }
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ new JsonFormat(true).encode(stream, slime);
+ }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/RootHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/RootHandler.java
new file mode 100644
index 00000000000..9283b1c3018
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/RootHandler.java
@@ -0,0 +1,96 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.logging.AccessLog;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.util.concurrent.Executor;
+
+/**
+ * Responds to requests for the root path of the controller by listing the available web service API's.
+ *
+ * FAQ:
+ * - Q: Why do we need this when the container provides a perfectly fine root response listing all handlers by default?
+ * - A: Because we also have Jersey API's and those are not included in the default response.
+ *
+ * @author Oyvind Gronnesby
+ * @author bratseth
+ */
+public class RootHandler extends LoggingRequestHandler {
+
+ public RootHandler(Executor executor, AccessLog accessLog) {
+ super(executor, accessLog);
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest httpRequest) {
+ final URI requestUri = httpRequest.getUri();
+ return new ControllerRootPathResponse(requestUri);
+ }
+
+ private static class ControllerRootPathResponse extends HttpResponse {
+
+ private final URI uri;
+
+ public ControllerRootPathResponse(URI uri) {
+ super(200);
+ this.uri = uri;
+ }
+
+ @Override
+ public void render(OutputStream outputStream) throws IOException {
+ ObjectMapper mapper = new ObjectMapper();
+ mapper.writeValue(outputStream, buildResponseObject());
+ }
+
+ @Override
+ public String getContentType() {
+ return "application/json";
+ }
+
+ private JsonNode buildResponseObject() {
+ ObjectNode output = new ObjectNode(JsonNodeFactory.instance);
+ ArrayNode services = output.putArray("services");
+
+ jerseyService(services, "provision", "/provision/v1/", "/provision/application.wadl");
+ jerseyService(services, "statuspage", "/statuspage/v1/", "/statuspage/application.wadl");
+ jerseyService(services, "zone", "/zone/v1/", "/zone/application.wadl");
+ jerseyService(services, "zone", "/zone/v2/", "/zone/application.wadl");
+ jerseyService(services, "cost", "/cost/v1/", "/cost/application.wadl");
+ handlerService(services, "application", "/application/v4/");
+ handlerService(services, "deployment", "/deployment/v1/");
+ handlerService(services, "screwdriver", "/screwdriver/v1/release/vespa");
+
+ return output;
+ }
+
+ private void jerseyService(ArrayNode parent, String name, String url, String wadl) {
+ ObjectNode service = parent.addObject();
+ service.put("name", name);
+ service.put("url", controllerUri(url));
+ service.put("wadl", controllerUri(wadl));
+ }
+
+ private void handlerService(ArrayNode parent, String name, String url) {
+ ObjectNode service = parent.addObject();
+ service.put("name", name);
+ service.put("url", controllerUri(url));
+ }
+
+ private String controllerUri(String path) {
+ return uri.resolve(path).toString();
+ }
+
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/SlimeJsonResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/SlimeJsonResponse.java
new file mode 100644
index 00000000000..81b07b81efb
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/SlimeJsonResponse.java
@@ -0,0 +1,38 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi;
+
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.JsonFormat;
+import com.yahoo.slime.Slime;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * A generic Json response using Slime for JSON encoding
+ *
+ * @author bratseth
+ */
+public class SlimeJsonResponse extends HttpResponse {
+
+ private final Slime slime;
+
+ public SlimeJsonResponse(Slime slime) {
+ super(200);
+ this.slime = slime;
+ }
+
+ public SlimeJsonResponse(int statusCode, Slime slime) {
+ super(statusCode);
+ this.slime = slime;
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ new JsonFormat(true).encode(stream, slime);
+ }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/StringResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/StringResponse.java
new file mode 100644
index 00000000000..1fc30b7d880
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/StringResponse.java
@@ -0,0 +1,26 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi;
+
+import com.yahoo.container.jdisc.HttpResponse;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author bratseth
+ */
+public class StringResponse extends HttpResponse {
+
+ private final String message;
+
+ public StringResponse(String message) {
+ super(200);
+ this.message = message;
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ stream.write(message.getBytes("utf-8"));
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Uri.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Uri.java
new file mode 100644
index 00000000000..479e7434f9b
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/Uri.java
@@ -0,0 +1,64 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+/**
+ * A Uri which provides convenience methods for creating various manipulated copies.
+ * This is immutable.
+ *
+ * @author bratseth
+ */
+public class Uri {
+
+ /** The URI instance wrapped by this */
+ private final URI uri;
+
+ public Uri(URI uri) {
+ this.uri = uri;
+ }
+
+ public Uri(String uri) {
+ try {
+ this.uri = new URI(uri);
+ }
+ catch (URISyntaxException e) {
+ throw new IllegalArgumentException("Invalid URI", e);
+ }
+ }
+
+ /** Returns a uri with the given path appended and all parameters removed */
+ public Uri append(String pathElement) {
+ return new Uri(withoutParameters().withTrailingSlash() + pathElement);
+ }
+
+ public Uri withoutParameters() {
+ int parameterStart = uri.toString().indexOf("?");
+ if (parameterStart < 0)
+ return new Uri(uri.toString());
+ else
+ return new Uri(uri.toString().substring(0, parameterStart));
+ }
+
+ public Uri withPath(String path) {
+ try {
+ return new Uri(new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(),
+ uri.getPort(), path, uri.getQuery(), uri.getFragment()));
+ }
+ catch (URISyntaxException e) {
+ throw new IllegalArgumentException("Could not add path '" + path + "' to " + this);
+ }
+ }
+
+ public Uri withTrailingSlash() {
+ if (toString().endsWith("/")) return this;
+ return new Uri(toString() + "/");
+ }
+
+ public URI toURI() { return uri; }
+
+ @Override
+ public String toString() { return uri.toString(); }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java
new file mode 100644
index 00000000000..d701f3d57a0
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java
@@ -0,0 +1,1065 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.application;
+
+import com.google.common.base.Joiner;
+import com.yahoo.component.Version;
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.ApplicationName;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.RegionName;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.config.provision.Zone;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.io.IOUtils;
+import com.yahoo.log.LogLevel;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Inspector;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.config.SlimeUtils;
+import com.yahoo.vespa.curator.Lock;
+import com.yahoo.vespa.hosted.controller.AlreadyExistsException;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.NotExistsException;
+import com.yahoo.vespa.hosted.controller.api.ActivateResult;
+import com.yahoo.vespa.hosted.controller.api.InstanceEndpoints;
+import com.yahoo.vespa.hosted.controller.api.Tenant;
+import com.yahoo.vespa.hosted.controller.api.application.v4.ApplicationResource;
+import com.yahoo.vespa.hosted.controller.api.application.v4.EnvironmentResource;
+import com.yahoo.vespa.hosted.controller.api.application.v4.TenantResource;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.DeployOptions;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.EndpointStatus;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.GitRevision;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.ScrewdriverBuildJob;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.configserverbindings.RefeedAction;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.configserverbindings.RestartAction;
+import com.yahoo.vespa.hosted.controller.api.application.v4.model.configserverbindings.ServiceInfo;
+import com.yahoo.vespa.hosted.controller.api.identifiers.AthensDomain;
+import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.GitBranch;
+import com.yahoo.vespa.hosted.controller.api.identifiers.GitCommit;
+import com.yahoo.vespa.hosted.controller.api.identifiers.GitRepository;
+import com.yahoo.vespa.hosted.controller.api.identifiers.Hostname;
+import com.yahoo.vespa.hosted.controller.api.identifiers.Property;
+import com.yahoo.vespa.hosted.controller.api.identifiers.PropertyId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.ScrewdriverId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.UserGroup;
+import com.yahoo.vespa.hosted.controller.api.identifiers.UserId;
+import com.yahoo.vespa.hosted.controller.api.integration.MetricsService;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.AthensPrincipal;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.NToken;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsException;
+import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServerException;
+import com.yahoo.vespa.hosted.controller.api.integration.configserver.Log;
+import com.yahoo.vespa.hosted.controller.api.integration.cost.ApplicationCost;
+import com.yahoo.vespa.hosted.controller.api.integration.cost.CostJsonModelAdapter;
+import com.yahoo.vespa.hosted.controller.api.integration.routing.RotationStatus;
+import com.yahoo.vespa.hosted.controller.application.ApplicationPackage;
+import com.yahoo.vespa.hosted.controller.application.ApplicationRevision;
+import com.yahoo.vespa.hosted.controller.application.Change;
+import com.yahoo.vespa.hosted.controller.application.Deployment;
+import com.yahoo.vespa.hosted.controller.application.JobStatus;
+import com.yahoo.vespa.hosted.controller.application.SourceRevision;
+import com.yahoo.vespa.hosted.controller.common.NotFoundCheckedException;
+import com.yahoo.vespa.hosted.controller.restapi.ErrorResponse;
+import com.yahoo.vespa.hosted.controller.restapi.MessageResponse;
+import com.yahoo.vespa.hosted.controller.restapi.Path;
+import com.yahoo.vespa.hosted.controller.restapi.ResourceResponse;
+import com.yahoo.vespa.hosted.controller.restapi.SlimeJsonResponse;
+import com.yahoo.vespa.hosted.controller.restapi.StringResponse;
+import com.yahoo.vespa.hosted.controller.restapi.filter.SetBouncerPassthruHeaderFilter;
+import com.yahoo.vespa.serviceview.bindings.ApplicationView;
+import com.yahoo.yolean.Exceptions;
+
+import javax.ws.rs.BadRequestException;
+import javax.ws.rs.ForbiddenException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.Principal;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+
+/**
+ * This implements the application/v4 API which is used to deploy and manage applications
+ * on hosted Vespa.
+ *
+ * @author bratseth
+ */
+public class ApplicationApiHandler extends LoggingRequestHandler {
+
+ private final Controller controller;
+ private final Authorizer authorizer;
+
+ public ApplicationApiHandler(Executor executor, AccessLog accessLog, Controller controller, Authorizer authorizer) {
+ super(executor, accessLog);
+ this.controller = controller;
+ this.authorizer = authorizer;
+ }
+
+ @Override
+ public Duration getTimeout() {
+ return Duration.ofMinutes(20); // deploys may take a long time;
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest request) {
+ try {
+ switch (request.getMethod()) {
+ case GET: return handleGET(request);
+ case PUT: return handlePUT(request);
+ case POST: return handlePOST(request);
+ case DELETE: return handleDELETE(request);
+ case OPTIONS: return handleOPTIONS(request);
+ default: return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported");
+ }
+ }
+ catch (ForbiddenException e) {
+ return ErrorResponse.forbidden(Exceptions.toMessageString(e));
+ }
+ catch (NotExistsException e) {
+ return ErrorResponse.notFoundError(Exceptions.toMessageString(e));
+ }
+ catch (IllegalArgumentException e) {
+ return ErrorResponse.badRequest(Exceptions.toMessageString(e));
+ }
+ catch (ConfigServerException e) {
+ return ErrorResponse.from(e);
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Unexpected error handling '" + request.getUri() + "'", e);
+ return ErrorResponse.internalServerError(Exceptions.toMessageString(e));
+ }
+ }
+
+ private HttpResponse handleGET(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/application/v4/")) return root(request);
+ if (path.matches("/application/v4/user")) return authenticatedUser(request);
+ if (path.matches("/application/v4/tenant")) return tenants(request);
+ if (path.matches("/application/v4/tenant-pipeline")) return tenantPipelines();
+ if (path.matches("/application/v4/athensDomain")) return athensDomains(request);
+ if (path.matches("/application/v4/property")) return properties(request);
+ if (path.matches("/application/v4/cookiefreshness")) return cookieFreshness(request);
+ if (path.matches("/application/v4/tenant/{tenant}")) return tenant(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application")) return applications(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return application(path.get("tenant"), path.get("application"), path, request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}")) return deployment(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/converge")) return waitForConvergence(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/service")) return services(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/service/{service}/{*}")) return service(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), path.get("service"), path.getRest(), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation")) return rotationStatus(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"));
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation/override"))
+ return getGlobalRotationOverride(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"));
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handlePUT(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/application/v4/user")) return createUser(request);
+ if (path.matches("/application/v4/tenant/{tenant}")) return updateTenant(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation/override"))
+ return setGlobalRotationOverride(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), false, request);
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handlePOST(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/application/v4/tenant/{tenant}/migrateTenantToAthens")) return migrateTenant(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}")) return createTenant(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return createApplication(path.get("tenant"), path.get("application"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/promote")) return promoteApplication(path.get("tenant"), path.get("application"));
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/deploying")) return deploy(path.get("tenant"), path.get("application"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}")) return deploy(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/deploy")) return deploy(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request); // legacy synonym of the above
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/restart")) return restart(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/log")) return log(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"));
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/promote")) return promoteApplicationDeployment(path.get("tenant"), path.get("application"), path.get("environment"), path.get("region"));
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handleDELETE(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/application/v4/tenant/{tenant}")) return deleteTenant(path.get("tenant"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}")) return deleteApplication(path.get("tenant"), path.get("application"), request);
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}")) return deactivate(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"));
+ if (path.matches("/application/v4/tenant/{tenant}/application/{application}/environment/{environment}/region/{region}/instance/{instance}/global-rotation/override"))
+ return setGlobalRotationOverride(path.get("tenant"), path.get("application"), path.get("instance"), path.get("environment"), path.get("region"), true, request);
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handleOPTIONS(HttpRequest request) {
+ // We implement this to avoid redirect loops on OPTIONS requests from browsers, but do not really bother
+ // spelling out the methods supported at each path, which we should
+ EmptyJsonResponse response = new EmptyJsonResponse();
+ response.headers().put("Allow", "GET,PUT,POST,DELETE,OPTIONS");
+ return response;
+ }
+
+ private HttpResponse root(HttpRequest request) {
+ return new ResourceResponse(request,
+ "user", "tenant", "tenant-pipeline", "athensDomain", "property", "cookiefreshness");
+ }
+
+ private HttpResponse authenticatedUser(HttpRequest request) {
+ String userIdString = request.getProperty("userOverride");
+ if (userIdString == null)
+ userIdString = userFrom(request)
+ .orElseThrow(() -> new ForbiddenException("You must be authenticated or specify userOverride"));
+ UserId userId = new UserId(userIdString);
+
+ List<Tenant> tenants = controller.tenants().asList(userId);
+
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+ response.setString("user", userId.id());
+ Cursor tenantsArray = response.setArray("tenants");
+ for (Tenant tenant : tenants)
+ tenantInTenantsListToSlime(tenant, request.getUri(), tenantsArray.addObject());
+ response.setBool("tenantExists", tenants.stream().map(Tenant::getId).anyMatch(id -> id.isTenantFor(userId)));
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse tenants(HttpRequest request) {
+ Slime slime = new Slime();
+ Cursor response = slime.setArray();
+ for (Tenant tenant : controller.tenants().asList())
+ tenantInTenantsListToSlime(tenant, request.getUri(), response.addObject());
+ return new SlimeJsonResponse(slime);
+ }
+
+ /** Lists the screwdriver project id for each application */
+ private HttpResponse tenantPipelines() {
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+ Cursor pipelinesArray = response.setArray("tenantPipelines");
+ for (Application application : controller.applications().asList()) {
+ if ( ! application.deploymentJobs().projectId().isPresent()) continue;
+
+ Cursor pipelineObject = pipelinesArray.addObject();
+ pipelineObject.setString("screwdriverId", String.valueOf(application.deploymentJobs().projectId().get()));
+ pipelineObject.setString("tenant", application.id().tenant().value());
+ pipelineObject.setString("application", application.id().application().value());
+ pipelineObject.setString("instance", application.id().instance().value());
+ }
+ response.setArray("brokenTenantPipelines"); // not used but may need to be present
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse athensDomains(HttpRequest request) {
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+ Cursor array = response.setArray("data");
+ for (AthensDomain athensDomain : controller.getDomainList(request.getProperty("prefix"))) {
+ array.addString(athensDomain.id());
+ }
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse properties(HttpRequest request) {
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+ Cursor array = response.setArray("properties");
+ for (Map.Entry<PropertyId, Property> entry : controller.fetchPropertyList().entrySet()) {
+ Cursor propertyObject = array.addObject();
+ propertyObject.setString("propertyid", entry.getKey().id());
+ propertyObject.setString("property", entry.getValue().id());
+ }
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse cookieFreshness(HttpRequest request) {
+ Slime slime = new Slime();
+ String passThruHeader = request.getHeader(SetBouncerPassthruHeaderFilter.BOUNCER_PASSTHRU_HEADER_FIELD);
+ slime.setObject().setBool("shouldRefreshCookie",
+ ! SetBouncerPassthruHeaderFilter.BOUNCER_PASSTHRU_COOKIE_OK.equals(passThruHeader));
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse tenant(String tenantName, HttpRequest request) {
+ Optional<Tenant> tenant = controller.tenants().tenant(new TenantId(tenantName));
+ if ( ! tenant.isPresent())
+ return ErrorResponse.notFoundError("Tenant '" + tenantName + "' does not exist");
+ return new SlimeJsonResponse(toSlime(tenant.get(), request, true));
+ }
+
+ private HttpResponse applications(String tenantName, HttpRequest request) {
+ TenantName tenant = TenantName.from(tenantName);
+ Slime slime = new Slime();
+ Cursor array = slime.setArray();
+ for (Application application : controller.applications().asList(tenant))
+ toSlime(application, array.addObject(), request);
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse application(String tenantName, String applicationName, Path path, HttpRequest request) {
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+
+ com.yahoo.config.provision.ApplicationId applicationId = com.yahoo.config.provision.ApplicationId.from(tenantName, applicationName, "default");
+ Application application =
+ controller.applications().get(applicationId)
+ .orElseThrow(() -> new NotExistsException(applicationId + " not found"));
+
+ // Currently deploying change
+ if (application.deploying().isPresent()) {
+ Cursor deployingObject = response.setObject("deploying");
+ if (application.deploying().get() instanceof Change.VersionChange)
+ deployingObject.setString("version", ((Change.VersionChange)application.deploying().get()).version().toString());
+ else if (((Change.ApplicationChange)application.deploying().get()).revision().isPresent())
+ toSlime(((Change.ApplicationChange)application.deploying().get()).revision().get(), deployingObject.setObject("revision"));
+ }
+
+ // Deployment jobs
+ Cursor deploymentsArray = response.setArray("deploymentJobs");
+ for (JobStatus job : application.deploymentJobs().jobStatus().values()) {
+ Cursor jobObject = deploymentsArray.addObject();
+ jobObject.setString("type", job.type().id());
+ jobObject.setBool("success", job.isSuccess());
+
+ job.lastTriggered().ifPresent(jobRun -> toSlime(jobRun, jobObject.setObject("lastTriggered")));
+ job.lastCompleted().ifPresent(jobRun -> toSlime(jobRun, jobObject.setObject("lastCompleted")));
+ job.firstFailing().ifPresent(jobRun -> toSlime(jobRun, jobObject.setObject("firstFailing")));
+ job.lastSuccess().ifPresent(jobRun -> toSlime(jobRun, jobObject.setObject("lastSuccess")));
+ }
+
+ // Compile version. The version that should be used when building an application
+ response.setString("compileVersion", application.compileVersion(controller).toFullString());
+
+ // Rotations
+ Cursor globalRotationsArray = response.setArray("globalRotations");
+ Set<URI> rotations = controller.getRotationUris(applicationId);
+ Map<String, RotationStatus> rotationHealthStatus =
+ rotations.isEmpty() ? Collections.emptyMap() : controller.getHealthStatus(rotations.iterator().next().getHost());
+ for (URI rotation : rotations)
+ globalRotationsArray.addString(rotation.toString());
+
+ // Deployments
+ Cursor instancesArray = response.setArray("instances");
+ for (Deployment deployment : application.deployments().values()) {
+ Cursor deploymentObject = instancesArray.addObject();
+ deploymentObject.setString("environment", deployment.zone().environment().value());
+ deploymentObject.setString("region", deployment.zone().region().value());
+ deploymentObject.setString("instance", application.id().instance().value()); // pointless
+ if ( ! rotations.isEmpty())
+ setRotationStatus(deployment, rotationHealthStatus, deploymentObject);
+ deploymentObject.setString("url", withPath(path.asString() +
+ "/environment/" + deployment.zone().environment().value() +
+ "/region/" + deployment.zone().region().value() +
+ "/instance/" + application.id().instance().value(),
+ request.getUri()).toString());
+ }
+
+ // Metrics
+ try {
+ MetricsService.ApplicationMetrics metrics = controller.metricsService().getApplicationMetrics(applicationId);
+ Cursor metricsObject = response.setObject("metrics");
+ metricsObject.setDouble("queryServiceQuality", metrics.queryServiceQuality());
+ metricsObject.setDouble("writeServiceQuality", metrics.writeServiceQuality());
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Failed getting Yamas metrics", e);
+ }
+
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse deployment(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ ApplicationId id = ApplicationId.from(tenantName, applicationName, instanceName);
+ Application application = controller.applications().get(id)
+ .orElseThrow(() -> new NotExistsException(id + " not found"));
+
+ DeploymentId deploymentId = new DeploymentId(application.id(),
+ new Zone(Environment.from(environment), RegionName.from(region)));
+
+ Deployment deployment = application.deployments().get(deploymentId.zone());
+ if (deployment == null)
+ throw new NotExistsException(application + " is not deployed in " + deploymentId.zone());
+
+ Optional<InstanceEndpoints> deploymentEndpoints = controller.applications().getDeploymentEndpoints(deploymentId);
+
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+ Cursor serviceUrlArray = response.setArray("serviceUrls");
+ if (deploymentEndpoints.isPresent()) {
+ for (URI uri : deploymentEndpoints.get().getContainerEndpoints())
+ serviceUrlArray.addString(uri.toString());
+ }
+
+ response.setString("nodes", withPath("/zone/v2/" + environment + "/" + region + "/nodes/v2/node/?&recursive=true&application=" + tenantName + "." + applicationName + "." + instanceName, request.getUri()).toString());
+
+ Environment env = Environment.from(environment);
+ RegionName regionName = RegionName.from(region);
+ URI elkUrl = controller.getElkUri(env, regionName, deploymentId);
+ if (elkUrl != null)
+ response.setString("elkUrl", elkUrl.toString());
+
+ response.setString("yamasUrl", monitoringSystemUri(deploymentId).toString());
+ response.setString("version", deployment.version().toFullString());
+ response.setString("revision", deployment.revision().id());
+ response.setLong("deployTimeEpochMs", deployment.at().toEpochMilli());
+ Optional<Duration> deploymentTimeToLive = controller.zoneRegistry().getDeploymentTimeToLive(Environment.from(environment), RegionName.from(region));
+ deploymentTimeToLive.ifPresent(duration -> response.setLong("expiryTimeEpochMs", deployment.at().plus(duration).toEpochMilli()));
+
+ application.deploymentJobs().projectId().ifPresent(i -> response.setString("screwdriverId", String.valueOf(i)));
+ sourceRevisionToSlime(deployment.revision().source(), response);
+
+ com.yahoo.config.provision.ApplicationId applicationId = com.yahoo.config.provision.ApplicationId.from(tenantName, applicationName, instanceName);
+ Zone zoneId = new Zone(Environment.from(environment), RegionName.from(region));
+
+ // Cost
+ try {
+ ApplicationCost appCost = controller.getApplicationCost(applicationId, zoneId);
+ Cursor costObject = response.setObject("cost");
+ CostJsonModelAdapter.toSlime(appCost, costObject);
+ } catch (NotFoundCheckedException nfce) {
+ log.log(Level.FINE, "Application cost data not found. " + nfce.getMessage());
+ }
+
+ // Metrics
+ try {
+ MetricsService.DeploymentMetrics metrics = controller.metricsService().getDeploymentMetrics(applicationId, zoneId);
+ Cursor metricsObject = response.setObject("metrics");
+ metricsObject.setDouble("queriesPerSecond", metrics.queriesPerSecond());
+ metricsObject.setDouble("writesPerSecond", metrics.writesPerSecond());
+ metricsObject.setDouble("documentCount", metrics.documentCount());
+ metricsObject.setDouble("queryLatencyMillis", metrics.queryLatencyMillis());
+ metricsObject.setDouble("writeLatencyMillis", metrics.writeLatencyMillis());
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Failed getting Yamas metrics", e);
+ }
+
+ return new SlimeJsonResponse(slime);
+ }
+
+ private void toSlime(ApplicationRevision revision, Cursor object) {
+ object.setString("hash", revision.id());
+ if (revision.source().isPresent())
+ sourceRevisionToSlime(revision.source(), object.setObject("source"));
+ }
+
+ private void sourceRevisionToSlime(Optional<SourceRevision> revision, Cursor object) {
+ if ( ! revision.isPresent()) return;
+ object.setString("gitRepository", revision.get().repository());
+ object.setString("gitBranch", revision.get().branch());
+ object.setString("gitCommit", revision.get().commit());
+ }
+
+ private URI monitoringSystemUri(DeploymentId deploymentId) {
+ return controller.zoneRegistry().getMonitoringSystemUri(deploymentId.zone().environment(),
+ deploymentId.zone().region(),
+ deploymentId.applicationId());
+ }
+
+ private HttpResponse setGlobalRotationOverride(String tenantName, String applicationName, String instanceName, String environment, String region, boolean inService, HttpRequest request) {
+
+ // Check if request is authorized
+ Optional<Tenant> existingTenant = controller.tenants().tenant(new TenantId(tenantName));
+ if (!existingTenant.isPresent())
+ return ErrorResponse.notFoundError("Tenant '" + tenantName + "' does not exist");
+
+ authorizer.throwIfUnauthorized(existingTenant.get().getId(), request);
+
+ // Decode payload (reason) and construct parameter to the configserver
+
+ Inspector requestData = toSlime(request.getData()).get();
+ String reason = mandatory("reason", requestData).asString();
+ String agent = authorizer.getUserId(request).toString();
+ long timestamp = controller.clock().instant().getEpochSecond();
+ EndpointStatus.Status status = inService ? EndpointStatus.Status.in : EndpointStatus.Status.out;
+ EndpointStatus endPointStatus = new EndpointStatus(status, reason, agent, timestamp);
+
+ // DeploymentId identifies the zone and application we are dealing with
+ DeploymentId deploymentId = new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName),
+ new Zone(Environment.from(environment), RegionName.from(region)));
+ try {
+ List<String> rotations = controller.applications().setGlobalRotationStatus(deploymentId, endPointStatus);
+ return new MessageResponse(String.format("Rotations %s successfully set to %s service", rotations.toString(), inService ? "in" : "out of"));
+ } catch (IOException e) {
+ return ErrorResponse.internalServerError("Unable to alter rotation status: " + e.getMessage());
+ }
+ }
+
+ private HttpResponse getGlobalRotationOverride(String tenantName, String applicationName, String instanceName, String environment, String region) {
+
+ DeploymentId deploymentId = new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName),
+ new Zone(Environment.from(environment), RegionName.from(region)));
+
+ Slime slime = new Slime();
+ Cursor c1 = slime.setObject().setArray("globalrotationoverride");
+ try {
+ Map<String, EndpointStatus> rotations = controller.applications().getGlobalRotationStatus(deploymentId);
+ for (String rotation : rotations.keySet()) {
+ EndpointStatus currentStatus = rotations.get(rotation);
+ c1.addString(rotation);
+ Cursor c2 = c1.addObject();
+ c2.setString("status", currentStatus.getStatus().name());
+ c2.setString("reason", currentStatus.getReason() == null ? "" : currentStatus.getReason());
+ c2.setString("agent", currentStatus.getAgent() == null ? "" : currentStatus.getAgent());
+ c2.setLong("timestamp", currentStatus.getEpoch());
+ }
+ } catch (IOException e) {
+ return ErrorResponse.internalServerError("Unable to get rotation status: " + e.getMessage());
+ }
+
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse rotationStatus(String tenantName, String applicationName, String instanceName, String environment, String region) {
+
+ ApplicationId applicationId = ApplicationId.from(tenantName, applicationName, instanceName);
+ Set<URI> rotations = controller.getRotationUris(applicationId);
+ if (rotations.isEmpty())
+ throw new NotExistsException("global rotation does not exist for '" + environment + "." + region + "'");
+
+ Slime slime = new Slime();
+ Cursor response = slime.setObject();
+
+ Map<String, RotationStatus> rotationHealthStatus = controller.getHealthStatus(rotations.iterator().next().getHost());
+
+ for (String rotationEndpoint : rotationHealthStatus.keySet()) {
+ if (rotationEndpoint.contains(toDns(environment)) && rotationEndpoint.contains(toDns(region))) {
+ Cursor bcpStatusObject = response.setObject("bcpStatus");
+ bcpStatusObject.setString("rotationStatus", rotationHealthStatus.getOrDefault(rotationEndpoint, RotationStatus.UNKNOWN).name());
+ }
+ }
+
+ return new SlimeJsonResponse(slime);
+ }
+
+ private HttpResponse waitForConvergence(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ return new JacksonJsonResponse(controller.waitForConfigConvergence(new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName),
+ new Zone(Environment.from(environment), RegionName.from(region))),
+ asLong(request.getProperty("timeout"), 1000)));
+ }
+
+ private HttpResponse services(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ ApplicationView applicationView = controller.getApplicationView(tenantName, applicationName, instanceName, environment, region);
+ ServiceApiResponse response = new ServiceApiResponse(new Zone(Environment.from(environment), RegionName.from(region)),
+ new com.yahoo.config.provision.ApplicationId.Builder().tenant(tenantName).applicationName(applicationName).instanceName(instanceName).build(),
+ controller.getConfigServerUris(Environment.from(environment), RegionName.from(region)),
+ request.getUri());
+ response.setResponse(applicationView);
+ return response;
+ }
+
+ private HttpResponse service(String tenantName, String applicationName, String instanceName, String environment, String region, String serviceName, String restPath, HttpRequest request) {
+ Map<?,?> result = controller.getServiceApiResponse(tenantName, applicationName, instanceName, environment, region, serviceName, restPath);
+ ServiceApiResponse response = new ServiceApiResponse(new Zone(Environment.from(environment), RegionName.from(region)),
+ new com.yahoo.config.provision.ApplicationId.Builder().tenant(tenantName).applicationName(applicationName).instanceName(instanceName).build(),
+ controller.getConfigServerUris(Environment.from(environment), RegionName.from(region)),
+ request.getUri());
+ response.setResponse(result, serviceName, restPath);
+ return response;
+ }
+
+ private HttpResponse createUser(HttpRequest request) {
+ Optional<String> username = userFrom(request);
+ if ( ! username.isPresent() ) throw new ForbiddenException("Not authenticated.");
+
+ try {
+ controller.tenants().createUserTenant(username.get());
+ return new MessageResponse("Created user '" + username.get() + "'");
+ } catch (AlreadyExistsException e) {
+ // Ok
+ return new MessageResponse("User '" + username + "' already exists");
+ }
+ }
+
+ private HttpResponse updateTenant(String tenantName, HttpRequest request) {
+ Optional<Tenant> existingTenant = controller.tenants().tenant(new TenantId(tenantName));
+ if ( ! existingTenant.isPresent()) return ErrorResponse.notFoundError("Tenant '" + tenantName + "' does not exist");;
+
+ Inspector requestData = toSlime(request.getData()).get();
+
+ authorizer.throwIfUnauthorized(existingTenant.get().getId(), request);
+ Tenant updatedTenant;
+ switch (existingTenant.get().tenantType()) {
+ case USER: {
+ throw new BadRequestException("Cannot set property or OpsDB user group for user tenant");
+ }
+ case OPSDB: {
+ UserGroup userGroup = new UserGroup(mandatory("userGroup", requestData).asString());
+ updatedTenant = Tenant.createOpsDbTenant(new TenantId(tenantName),
+ userGroup,
+ new Property(mandatory("property", requestData).asString()),
+ optional("propertyId", requestData).map(PropertyId::new));
+ throwIfNotSuperUserOrPartOfOpsDbGroup(userGroup, request);
+ controller.tenants().updateTenant(updatedTenant, authorizer.getNToken(request));
+ break;
+ }
+ case ATHENS: {
+ if (requestData.field("userGroup").valid())
+ throw new BadRequestException("Cannot set OpsDB user group to Athens tenant");
+ updatedTenant = Tenant.createAthensTenant(new TenantId(tenantName),
+ new AthensDomain(mandatory("athensDomain", requestData).asString()),
+ new Property(mandatory("property", requestData).asString()),
+ optional("propertyId", requestData).map(PropertyId::new));
+ controller.tenants().updateTenant(updatedTenant, authorizer.getNToken(request));
+ break;
+ }
+ default: {
+ throw new BadRequestException("Unknown tenant type: " + existingTenant.get().tenantType());
+ }
+ }
+ return new SlimeJsonResponse(toSlime(updatedTenant, request, true));
+ }
+
+ private HttpResponse createTenant(String tenantName, HttpRequest request) {
+ if (new TenantId(tenantName).isUser())
+ return ErrorResponse.badRequest("Use User API to create user tenants.");
+
+ Inspector requestData = toSlime(request.getData()).get();
+
+ Tenant tenant = new Tenant(new TenantId(tenantName),
+ optional("userGroup", requestData).map(UserGroup::new),
+ optional("property", requestData).map(Property::new),
+ optional("athensDomain", requestData).map(AthensDomain::new),
+ optional("propertyId", requestData).map(PropertyId::new));
+ if (tenant.isOpsDbTenant())
+ throwIfNotSuperUserOrPartOfOpsDbGroup(new UserGroup(mandatory("userGroup", requestData).asString()), request);
+ if (tenant.isAthensTenant())
+ throwIfNotAthensDomainAdmin(new AthensDomain(mandatory("athensDomain", requestData).asString()), request);
+
+ controller.tenants().addTenant(tenant, authorizer.getNToken(request));
+ return new SlimeJsonResponse(toSlime(tenant, request, true));
+ }
+
+ private HttpResponse migrateTenant(String tenantName, HttpRequest request) {
+ TenantId tenantid = new TenantId(tenantName);
+ Inspector requestData = toSlime(request.getData()).get();
+ AthensDomain tenantDomain = new AthensDomain(mandatory("athensDomain", requestData).asString());
+ Property property = new Property(mandatory("property", requestData).asString());
+ PropertyId propertyId = new PropertyId(mandatory("propertyId", requestData).asString());
+
+ authorizer.throwIfUnauthorized(tenantid, request);
+ throwIfNotAthensDomainAdmin(tenantDomain, request);
+ NToken nToken = authorizer.getNToken(request)
+ .orElseThrow(() ->
+ new BadRequestException("The NToken for a domain admin is required to migrate tenant to Athens"));
+ Tenant tenant = controller.tenants().migrateTenantToAthens(tenantid, tenantDomain, propertyId, property, nToken);
+ return new SlimeJsonResponse(toSlime(tenant, request, true));
+ }
+
+ private HttpResponse createApplication(String tenantName, String applicationName, HttpRequest request) {
+ authorizer.throwIfUnauthorized(new TenantId(tenantName), request);
+ Application application;
+ try {
+ application = controller.applications().createApplication(com.yahoo.config.provision.ApplicationId.from(tenantName, applicationName, "default"), authorizer.getNToken(request));
+ }
+ catch (ZmsException e) { // TODO: Push conversion down
+ if (e.getCode() == com.yahoo.jdisc.Response.Status.FORBIDDEN)
+ throw new ForbiddenException("Not authorized to create application", e);
+ else
+ throw e;
+ }
+
+ Slime slime = new Slime();
+ toSlime(application, slime.setObject(), request);
+ return new SlimeJsonResponse(slime);
+ }
+
+ /** Trigger deployment of the last built application package, on a given version */
+ private HttpResponse deploy(String tenantName, String applicationName, HttpRequest request) {
+ ApplicationId id = ApplicationId.from(tenantName, applicationName, "default");
+ try (Lock lock = controller.applications().lock(id)) {
+ Application application = controller.applications().require(id);
+ if (application.deploying().isPresent())
+ throw new IllegalArgumentException("Can not start a deployment of " + application + " at this time: " +
+ application.deploying() + " is in progress");
+
+ Version version = decideDeployVersion(request);
+ if ( ! systemHasVersion(version))
+ throw new IllegalArgumentException("Cannot trigger deployment of version '" + version + "': " +
+ "Version is not active in this system. " +
+ "Active versions: " + controller.versionStatus().versions());
+
+ // Since we manually triggered it we don't want this to be self-triggering for the time being
+ controller.applications().store(application.with(application.deploymentJobs().asSelfTriggering(false)), lock);
+
+ controller.applications().deploymentTrigger().triggerChange(application.id(), new Change.VersionChange(version));
+ return new MessageResponse("Triggered deployment of " + application + " on version " + version);
+ }
+ }
+
+ private HttpResponse restart(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ DeploymentId deploymentId = new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName),
+ new Zone(Environment.from(environment), RegionName.from(region)));
+ // TODO: Propagate all filters
+ if (request.getProperty("hostname") != null)
+ controller.applications().restartHost(deploymentId, new Hostname(request.getProperty("hostname")));
+ else
+ controller.applications().restart(deploymentId);
+
+ // TODO: Change to return JSON
+ return new StringResponse("Requested restart of " + path(TenantResource.API_PATH, tenantName,
+ ApplicationResource.API_PATH, applicationName,
+ EnvironmentResource.API_PATH, environment,
+ "region", region,
+ "instance", instanceName));
+ }
+
+ /**
+ * This returns and deletes recent error logs from this deployment, which is used by tenant deployment jobs to verify that
+ * the application is working. It is called for all production zones, also those in which the application is not present,
+ * and possibly before it is present, so failures are normal and expected.
+ */
+ private HttpResponse log(String tenantName, String applicationName, String instanceName, String environment, String region) {
+ try {
+ DeploymentId deploymentId = new DeploymentId(ApplicationId.from(tenantName, applicationName, instanceName),
+ new Zone(Environment.from(environment), RegionName.from(region)));
+ return new JacksonJsonResponse(controller.grabLog(deploymentId));
+ }
+ catch (RuntimeException e) {
+ Slime slime = new Slime();
+ slime.setObject();
+ return new SlimeJsonResponse(slime);
+ }
+ }
+
+ private HttpResponse deploy(String tenantName, String applicationName, String instanceName, String environment, String region, HttpRequest request) {
+ ApplicationId applicationId = ApplicationId.from(tenantName, applicationName, instanceName);
+ Zone zone = new Zone(Environment.from(environment), RegionName.from(region));
+
+ Map<String, byte[]> dataParts = new MultipartParser().parse(request);
+ if ( ! dataParts.containsKey("deployOptions"))
+ return ErrorResponse.badRequest("Missing required form part 'deployOptions'");
+ if ( ! dataParts.containsKey("applicationZip"))
+ return ErrorResponse.badRequest("Missing required form part 'applicationZip'");
+
+ Inspector deployOptions = SlimeUtils.jsonToSlime(dataParts.get("deployOptions")).get();
+
+ DeployAuthorizer deployAuthorizer = new DeployAuthorizer(controller.athens(), controller.zoneRegistry());
+ Tenant tenant = controller.tenants().tenant(new TenantId(tenantName)).orElseThrow(() -> new NotExistsException(new TenantId(tenantName)));
+ Principal principal = authorizer.getPrincipal(request);
+ if (principal instanceof AthensPrincipal) {
+ deployAuthorizer.throwIfUnauthorizedForDeploy(principal,
+ Environment.from(environment),
+ tenant,
+ applicationId);
+ } else { // In case of host-based principal
+ UserId userId = new UserId(principal.getName());
+ deployAuthorizer.throwIfUnauthorizedForDeploy(
+ Environment.from(environment),
+ userId,
+ tenant,
+ applicationId,
+ optional("screwdriverBuildJob", deployOptions).map(ScrewdriverId::new));
+ }
+
+
+ // TODO: get rid of the json object
+ DeployOptions deployOptionsJsonClass = new DeployOptions(screwdriverBuildJobFromSlime(deployOptions.field("screwdriverBuildJob")),
+ optional("vespaVersion", deployOptions).map(Version::new),
+ deployOptions.field("ignoreValidationErrors").asBool(),
+ deployOptions.field("deployCurrentVersion").asBool());
+ ActivateResult result = controller.applications().deployApplication(applicationId,
+ zone,
+ new ApplicationPackage(dataParts.get("applicationZip")),
+ deployOptionsJsonClass);
+ return new SlimeJsonResponse(toSlime(result, dataParts.get("applicationZip").length));
+ }
+
+ private HttpResponse deleteTenant(String tenantName, HttpRequest request) {
+ Optional<Tenant> tenant = controller.tenants().tenant(new TenantId(tenantName));
+ if ( ! tenant.isPresent()) return ErrorResponse.notFoundError("Could not delete tenant '" + tenantName + "': Tenant not found"); // NOTE: The Jersey implementation would silently ignore this
+
+ authorizer.throwIfUnauthorized(new TenantId(tenantName), request);
+ controller.tenants().deleteTenant(new TenantId(tenantName), authorizer.getNToken(request));
+
+ // TODO: Change to a message response saying the tenant was deleted
+ return new SlimeJsonResponse(toSlime(tenant.get(), request, false));
+ }
+
+ private HttpResponse deleteApplication(String tenantName, String applicationName, HttpRequest request) {
+ authorizer.throwIfUnauthorized(new TenantId(tenantName), request);
+
+ com.yahoo.config.provision.ApplicationId id = com.yahoo.config.provision.ApplicationId.from(tenantName, applicationName, "default");
+ Application deleted = controller.applications().deleteApplication(id, authorizer.getNToken(request));
+ if (deleted == null)
+ return ErrorResponse.notFoundError("Could not delete application '" + id + "': Application not found");
+ return new EmptyJsonResponse(); // TODO: Replicates current behavior but should return a message response instead
+ }
+
+ private HttpResponse deactivate(String tenantName, String applicationName, String instanceName, String environment, String region) {
+ Application application = controller.applications().require(ApplicationId.from(tenantName, applicationName, instanceName));
+
+ Zone zone = new Zone(Environment.from(environment), RegionName.from(region));
+ Deployment deployment = application.deployments().get(zone);
+ if (deployment == null)
+ return ErrorResponse.notFoundError("Could not deactivate: " + application + " is not deployed in " + zone);
+
+ controller.applications().deactivate(application, deployment, false);
+
+ // TODO: Change to return JSON
+ return new StringResponse("Deactivated " + path(TenantResource.API_PATH, tenantName,
+ ApplicationResource.API_PATH, applicationName,
+ EnvironmentResource.API_PATH, environment,
+ "region", region,
+ "instance", instanceName));
+ }
+
+ /**
+ * Promote application Chef environments. To be used by component jobs only
+ */
+ private HttpResponse promoteApplication(String tenantName, String applicationName) {
+ try{
+ ApplicationChefEnvironment chefEnvironment = new ApplicationChefEnvironment(controller.system());
+ String sourceEnvironment = chefEnvironment.systemChefEnvironment();
+ String targetEnvironment = chefEnvironment.applicationSourceEnvironment(TenantName.from(tenantName), ApplicationName.from(applicationName));
+ controller.chefClient().copyChefEnvironment(sourceEnvironment, targetEnvironment);
+ return new MessageResponse(String.format("Successfully copied environment %s to %s", sourceEnvironment, targetEnvironment));
+ } catch (Exception e) {
+ log.log(LogLevel.ERROR, String.format("Error during Chef copy environment. (%s.%s)", tenantName, applicationName), e);
+ return ErrorResponse.internalServerError("Unable to promote Chef environments for application");
+ }
+ }
+
+ /**
+ * Promote application Chef environments for jobs that deploy applications
+ */
+ private HttpResponse promoteApplicationDeployment(String tenantName, String applicationName, String environmentName, String regionName) {
+ try {
+ ApplicationChefEnvironment chefEnvironment = new ApplicationChefEnvironment(controller.system());
+ String sourceEnvironment = chefEnvironment.applicationSourceEnvironment(TenantName.from(tenantName), ApplicationName.from(applicationName));
+ String targetEnvironment = chefEnvironment.applicationTargetEnvironment(TenantName.from(tenantName), ApplicationName.from(applicationName), Environment.from(environmentName), RegionName.from(regionName));
+ controller.chefClient().copyChefEnvironment(sourceEnvironment, targetEnvironment);
+ return new MessageResponse(String.format("Successfully copied environment %s to %s", sourceEnvironment, targetEnvironment));
+ } catch (Exception e) {
+ log.log(LogLevel.ERROR, String.format("Error during Chef copy environment. (%s.%s %s.%s)", tenantName, applicationName, environmentName, regionName), e);
+ return ErrorResponse.internalServerError("Unable to promote Chef environments for application");
+ }
+ }
+
+ private Optional<String> userFrom(HttpRequest request) {
+ return authorizer.getPrincipalIfAny(request).map(Principal::getName);
+ }
+
+ private void toSlime(Tenant tenant, Cursor object, HttpRequest request, boolean listApplications) {
+ object.setString("type", tenant.tenantType().name());
+ tenant.getAthensDomain().ifPresent(a -> object.setString("athensDomain", a.id()));
+ tenant.getProperty().ifPresent(p -> object.setString("property", p.id()));
+ tenant.getPropertyId().ifPresent(p -> object.setString("propertyId", p.toString()));
+ tenant.getUserGroup().ifPresent(g -> object.setString("userGroup", g.id()));
+ Cursor applicationArray = object.setArray("applications");
+ if (listApplications) { // This cludge is needed because we call this after deleting the tenant. As this call makes another tenant lookup it will fail. TODO is to support lookup on tenant
+ for (Application application : controller.applications().asList(TenantName.from(tenant.getId().id()))) {
+ if (application.id().instance().isDefault()) // TODO: Skip non-default applications until supported properly
+ toSlime(application, applicationArray.addObject(), request);
+ }
+ }
+ }
+
+ // A tenant has different content when in a list ... antipattern, but not solvable before application/v5
+ private void tenantInTenantsListToSlime(Tenant tenant, URI requestURI, Cursor object) {
+ object.setString("tenant", tenant.getId().id());
+ Cursor metaData = object.setObject("metaData");
+ metaData.setString("type", tenant.tenantType().name());
+ tenant.getAthensDomain().ifPresent(a -> metaData.setString("athensDomain", a.id()));
+ tenant.getProperty().ifPresent(p -> metaData.setString("property", p.id()));
+ tenant.getUserGroup().ifPresent(g -> metaData.setString("userGroup", g.id()));
+ object.setString("url", withPath("/application/v4/tenant/" + tenant.getId().id(), requestURI).toString());
+ }
+
+ /** Returns a copy of the given URI with the host and port from the given URI and the path set to the given path */
+ private URI withPath(String newPath, URI uri) {
+ try {
+ return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(), newPath, null, null);
+ }
+ catch (URISyntaxException e) {
+ throw new RuntimeException("Will not happen", e);
+ }
+ }
+
+ private void setRotationStatus(Deployment deployment, Map<String, RotationStatus> healthStatus, Cursor object) {
+ if ( ! deployment.zone().environment().equals(Environment.prod)) return;
+
+ Cursor bcpStatusObject = object.setObject("bcpStatus");
+ bcpStatusObject.setString("rotationStatus", findRotationStatus(deployment, healthStatus).name());
+ }
+
+ private RotationStatus findRotationStatus(Deployment deployment, Map<String, RotationStatus> healthStatus) {
+ for (String endpoint : healthStatus.keySet()) {
+ if (endpoint.contains(toDns(deployment.zone().environment().value())) &&
+ endpoint.contains(toDns(deployment.zone().region().value()))) {
+ return healthStatus.getOrDefault(endpoint, RotationStatus.UNKNOWN);
+ }
+ }
+
+ return RotationStatus.UNKNOWN;
+ }
+
+ private String toDns(String id) {
+ return id.replace('_', '-');
+ }
+
+ private long asLong(String valueOrNull, long defaultWhenNull) {
+ if (valueOrNull == null) return defaultWhenNull;
+ try {
+ return Long.parseLong(valueOrNull);
+ }
+ catch (NumberFormatException e) {
+ throw new IllegalArgumentException("Expected an integer but got '" + valueOrNull + "'");
+ }
+ }
+
+ private void toSlime(JobStatus.JobRun jobRun, Cursor object) {
+ object.setString("version", jobRun.version().toFullString());
+ jobRun.revision().ifPresent(revision -> toSlime(revision, object.setObject("revision")));
+ object.setLong("at", jobRun.at().toEpochMilli());
+ }
+
+ private Slime toSlime(InputStream jsonStream) {
+ try {
+ byte[] jsonBytes = IOUtils.readBytes(jsonStream, 1000 * 1000);
+ return SlimeUtils.jsonToSlime(jsonBytes);
+ } catch (IOException e) {
+ throw new RuntimeException();
+ }
+ }
+
+ private void throwIfNotSuperUserOrPartOfOpsDbGroup(UserGroup userGroup, HttpRequest request) {
+ UserId userId = authorizer.getUserId(request);
+ if (!authorizer.isSuperUser(request) && !authorizer.isGroupMember(userId, userGroup) ) {
+ throw new ForbiddenException(String.format("User '%s' is not super user or part of the OpsDB user group '%s'",
+ userId.id(), userGroup.id()));
+ }
+ }
+
+ private void throwIfNotAthensDomainAdmin(AthensDomain tenantDomain, HttpRequest request) {
+ UserId userId = authorizer.getUserId(request);
+ if ( ! authorizer.isAthensDomainAdmin(userId, tenantDomain)) {
+ throw new ForbiddenException(
+ String.format("The user '%s' is not admin in Athens domain '%s'", userId.id(), tenantDomain.id()));
+ }
+ }
+
+ private Inspector mandatory(String key, Inspector object) {
+ if ( ! object.field(key).valid())
+ throw new IllegalArgumentException("'" + key + "' is missing");
+ return object.field(key);
+ }
+
+ private Optional<String> optional(String key, Inspector object) {
+ return SlimeUtils.optionalString(object.field(key));
+ }
+
+ private static String path(Object... elements) {
+ return Joiner.on("/").join(elements);
+ }
+
+ private Slime toSlime(Tenant tenant, HttpRequest request, boolean listApplications) {
+ Slime slime = new Slime();
+ toSlime(tenant, slime.setObject(), request, listApplications);
+ return slime;
+ }
+
+ private void toSlime(Application application, Cursor object, HttpRequest request) {
+ object.setString("application", application.id().application().value());
+ object.setString("instance", application.id().instance().value());
+ object.setString("url", withPath("/application/v4/tenant/" + application.id().tenant().value() +
+ "/application/" + application.id().application().value(), request.getUri()).toString());
+ }
+
+ private Slime toSlime(ActivateResult result, long applicationZipSizeBytes) {
+ Slime slime = new Slime();
+ Cursor object = slime.setObject();
+ object.setString("revisionId", result.getRevisionId().id());
+ object.setLong("applicationZipSize", applicationZipSizeBytes);
+ Cursor logArray = object.setArray("prepareMessages");
+ if (result.getPrepareResponse().log != null) {
+ for (Log logMessage : result.getPrepareResponse().log) {
+ Cursor logObject = logArray.addObject();
+ logObject.setLong("time", logMessage.time);
+ logObject.setString("level", logMessage.level);
+ logObject.setString("message", logMessage.message);
+ }
+ }
+
+ Cursor changeObject = object.setObject("configChangeActions");
+
+ Cursor restartActionsArray = changeObject.setArray("restart");
+ for (RestartAction restartAction : result.getPrepareResponse().configChangeActions.restartActions) {
+ Cursor restartActionObject = restartActionsArray.addObject();
+ restartActionObject.setString("clusterName", restartAction.clusterName);
+ restartActionObject.setString("clusterType", restartAction.clusterType);
+ restartActionObject.setString("serviceType", restartAction.serviceType);
+ serviceInfosToSlime(restartAction.services, restartActionObject.setArray("services"));
+ stringsToSlime(restartAction.messages, restartActionObject.setArray("messages"));
+ }
+
+ Cursor refeedActionsArray = changeObject.setArray("refeed");
+ for (RefeedAction refeedAction : result.getPrepareResponse().configChangeActions.refeedActions) {
+ Cursor refeedActionObject = refeedActionsArray.addObject();
+ refeedActionObject.setString("name", refeedAction.name);
+ refeedActionObject.setBool("allowed", refeedAction.allowed);
+ refeedActionObject.setString("documentType", refeedAction.documentType);
+ refeedActionObject.setString("clusterName", refeedAction.clusterName);
+ serviceInfosToSlime(refeedAction.services, refeedActionObject.setArray("services"));
+ stringsToSlime(refeedAction.messages, refeedActionObject.setArray("messages"));
+ }
+ return slime;
+ }
+
+ private void serviceInfosToSlime(List<ServiceInfo> serviceInfoList, Cursor array) {
+ for (ServiceInfo serviceInfo : serviceInfoList) {
+ Cursor serviceInfoObject = array.addObject();
+ serviceInfoObject.setString("serviceName", serviceInfo.serviceName);
+ serviceInfoObject.setString("serviceType", serviceInfo.serviceType);
+ serviceInfoObject.setString("configId", serviceInfo.configId);
+ serviceInfoObject.setString("hostName", serviceInfo.hostName);
+ }
+ }
+
+ private void stringsToSlime(List<String> strings, Cursor array) {
+ for (String string : strings)
+ array.addString(string);
+ }
+
+ // TODO: get rid of the json object
+ private Optional<ScrewdriverBuildJob> screwdriverBuildJobFromSlime(Inspector object) {
+ if ( ! object.valid() ) return Optional.empty();
+ Optional<ScrewdriverId> screwdriverId = optional("screwdriverId", object).map(ScrewdriverId::new);
+ return Optional.of(new ScrewdriverBuildJob(screwdriverId.orElse(null),
+ gitRevisionFromSlime(object.field("gitRevision"))));
+ }
+
+ // TODO: get rid of the json object
+ private GitRevision gitRevisionFromSlime(Inspector object) {
+ return new GitRevision(optional("repository", object).map(GitRepository::new).orElse(null),
+ optional("branch", object).map(GitBranch::new).orElse(null),
+ optional("commit", object).map(GitCommit::new).orElse(null));
+ }
+
+ private String readToString(InputStream stream) {
+ Scanner scanner = new Scanner(stream).useDelimiter("\\A");
+ if ( ! scanner.hasNext()) return null;
+ return scanner.next();
+ }
+
+ private boolean systemHasVersion(Version version) {
+ return controller.versionStatus().versions().stream().anyMatch(v -> v.versionNumber().equals(version));
+ }
+
+ private Version decideDeployVersion(HttpRequest request) {
+ String requestVersion = readToString(request.getData());
+ if (requestVersion != null)
+ return new Version(requestVersion);
+ else
+ return controller.systemVersion();
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationChefEnvironment.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationChefEnvironment.java
new file mode 100644
index 00000000000..7c32e48e218
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationChefEnvironment.java
@@ -0,0 +1,43 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.application;
+
+import com.yahoo.config.provision.ApplicationName;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.config.provision.RegionName;
+import com.yahoo.config.provision.SystemName;
+import com.yahoo.config.provision.TenantName;
+
+/**
+ * Represents Chef environments for applications/deployments. Used for promotion of Chef environments
+ *
+ * @author mortent
+ */
+public class ApplicationChefEnvironment {
+
+ private final String systemChefEnvironment;
+ private final String systemSuffix;
+
+ public ApplicationChefEnvironment(SystemName system) {
+ if (system == SystemName.main) {
+ systemChefEnvironment = "hosted-verified-prod";
+ systemSuffix = "";
+ } else {
+ systemChefEnvironment = "hosted-infra-cd";
+ systemSuffix = "-cd";
+ }
+ }
+
+ public String systemChefEnvironment() {
+ return systemChefEnvironment;
+ }
+
+ public String applicationSourceEnvironment(TenantName tenantName, ApplicationName applicationName) {
+ // placeholder and component already used in legacy chef promotion
+ return String.format("hosted-instance%s_%s_%s_placeholder_component_default", systemSuffix, tenantName, applicationName);
+ }
+
+ public String applicationTargetEnvironment(TenantName tenantName, ApplicationName applicationName, Environment environment, RegionName regionName) {
+ return String.format("hosted-instance%s_%s_%s_%s_%s_default", systemSuffix, tenantName, applicationName, regionName, environment);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java
new file mode 100644
index 00000000000..8dff39779b9
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/Authorizer.java
@@ -0,0 +1,164 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.application;
+
+import com.google.common.collect.ImmutableSet;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.Tenant;
+import com.yahoo.vespa.hosted.controller.api.identifiers.AthensDomain;
+import com.yahoo.vespa.hosted.controller.api.identifiers.TenantId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.UserGroup;
+import com.yahoo.vespa.hosted.controller.api.identifiers.UserId;
+import com.yahoo.vespa.hosted.controller.api.integration.entity.EntityService;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsClientFactory;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.AthensPrincipal;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.Athens;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.NToken;
+import com.yahoo.vespa.hosted.controller.common.ContextAttributes;
+import com.yahoo.vespa.hosted.controller.restapi.filter.NTokenRequestFilter;
+import com.yahoo.vespa.hosted.controller.restapi.filter.UnauthenticatedUserPrincipal;
+
+import javax.ws.rs.ForbiddenException;
+import javax.ws.rs.HttpMethod;
+import javax.ws.rs.core.SecurityContext;
+import java.security.Principal;
+import java.util.Optional;
+import java.util.Set;
+import java.util.logging.Logger;
+
+
+/**
+ * @author Stian Kristoffersen
+ * @author Tony Vaagenes
+ * @author bjorncs
+ */
+// TODO: Make this an interface
+public class Authorizer {
+
+ private static final Logger log = Logger.getLogger(Authorizer.class.getName());
+
+ // Must be kept in sync with bouncer filter configuration.
+ private static final String VESPA_HOSTED_ADMIN_ROLE = "10707.A";
+
+ private static final Set<UserId> SCREWDRIVER_USERS = ImmutableSet.of(new UserId("screwdrv"),
+ new UserId("screwdriver"),
+ new UserId("sdrvtest"),
+ new UserId("screwdriver-test"));
+
+ private final Controller controller;
+ private final ZmsClientFactory zmsClientFactory;
+ private final EntityService entityService;
+ private final Athens athens;
+
+ public Authorizer(Controller controller, EntityService entityService) {
+ this.controller = controller;
+ this.zmsClientFactory = controller.athens().zmsClientFactory();
+ this.entityService = entityService;
+ this.athens = controller.athens();
+ }
+
+ public void throwIfUnauthorized(TenantId tenantId, HttpRequest request) throws ForbiddenException {
+ if (isReadOnlyMethod(request.getMethod().name())) return;
+ if (isSuperUser(request)) return;
+
+ Optional<Tenant> tenant = controller.tenants().tenant(tenantId);
+ if ( ! tenant.isPresent()) return;
+
+ UserId userId = getUserId(request);
+ if (isTenantAdmin(userId, tenant.get())) return;
+
+ throw loggedForbiddenException("User " + userId + " does not have write access to tenant " + tenantId);
+ }
+
+ public UserId getUserId(HttpRequest request) {
+ String name = getPrincipal(request).getName();
+ if (name == null)
+ throw loggedForbiddenException("Not authorized: User name is null");
+ return new UserId(name);
+ }
+
+ /** Returns the principal or throws forbidden */ // TODO: Avoid REST exceptions
+ public Principal getPrincipal(HttpRequest request) {
+ return getPrincipalIfAny(request).orElseThrow(() -> Authorizer.loggedForbiddenException("User is not authenticated"));
+ }
+
+ /** Returns the principal if there is any */
+ public Optional<Principal> getPrincipalIfAny(HttpRequest request) {
+ return securityContextOf(request).map(SecurityContext::getUserPrincipal);
+ }
+
+ public Optional<NToken> getNToken(HttpRequest request) {
+ String nTokenHeader = (String)request.getJDiscRequest().context().get(NTokenRequestFilter.NTOKEN_HEADER);
+ return Optional.ofNullable(nTokenHeader).map(athens::nTokenFrom);
+ }
+
+ public boolean isSuperUser(HttpRequest request) {
+ // TODO Check membership of admin role in Vespa's Athens domain
+ return isMemberOfVespaBouncerGroup(request) || isScrewdriverPrincipal(athens, getPrincipal(request));
+ }
+
+ public static boolean isScrewdriverPrincipal(Athens athens, Principal principal) {
+ if (principal instanceof UnauthenticatedUserPrincipal) // Host-based authentication
+ return SCREWDRIVER_USERS.contains(new UserId(principal.getName()));
+ else if (principal instanceof AthensPrincipal)
+ return ((AthensPrincipal)principal).getDomain().equals(athens.screwdriverDomain());
+ else
+ return false;
+ }
+
+ private static ForbiddenException loggedForbiddenException(String message, Object... args) {
+ String formattedMessage = String.format(message, args);
+ log.info(formattedMessage);
+ return new ForbiddenException(formattedMessage);
+ }
+
+ private boolean isTenantAdmin(UserId userId, Tenant tenant) {
+ switch (tenant.tenantType()) {
+ case ATHENS:
+ return isAthensTenantAdmin(userId, tenant.getAthensDomain().get());
+ case OPSDB:
+ return isGroupMember(userId, tenant.getUserGroup().get());
+ case USER:
+ return isUserTenantOwner(tenant.getId(), userId);
+ }
+ throw new IllegalArgumentException("Unknown tenant type: " + tenant.tenantType());
+ }
+
+ private boolean isAthensTenantAdmin(UserId userId, AthensDomain tenantDomain) {
+ return zmsClientFactory.createClientWithServicePrincipal()
+ .hasTenantAdminAccess(athens.principalFrom(userId), tenantDomain);
+ }
+
+ public boolean isAthensDomainAdmin(UserId userId, AthensDomain tenantDomain) {
+ return zmsClientFactory.createClientWithServicePrincipal()
+ .isDomainAdmin(athens.principalFrom(userId), tenantDomain);
+ }
+
+ public boolean isGroupMember(UserId userId, UserGroup userGroup) {
+ return entityService.isGroupMember(userId, userGroup);
+ }
+
+ private static boolean isUserTenantOwner(TenantId tenantId, UserId userId) {
+ return tenantId.equals(userId.toTenantId());
+ }
+
+ public static boolean environmentRequiresAuthorization(Environment environment) {
+ return environment != Environment.dev && environment != Environment.perf;
+ }
+
+ private static boolean isReadOnlyMethod(String method) {
+ return method.equals(HttpMethod.GET) || method.equals(HttpMethod.HEAD) || method.equals(HttpMethod.OPTIONS);
+ }
+
+ private boolean isMemberOfVespaBouncerGroup(HttpRequest request) {
+ Optional<SecurityContext> securityContext = securityContextOf(request);
+ if ( ! securityContext.isPresent() ) throw Authorizer.loggedForbiddenException("User is not authenticated");
+ return securityContext.get().isUserInRole(Authorizer.VESPA_HOSTED_ADMIN_ROLE);
+ }
+
+ protected Optional<SecurityContext> securityContextOf(HttpRequest request) {
+ return Optional.ofNullable((SecurityContext)request.getJDiscRequest().context().get(ContextAttributes.SECURITY_CONTEXT_ATTRIBUTE));
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/DeployAuthorizer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/DeployAuthorizer.java
new file mode 100644
index 00000000000..5c7cdfdae0a
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/DeployAuthorizer.java
@@ -0,0 +1,117 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.application;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.Environment;
+import com.yahoo.vespa.hosted.controller.api.Tenant;
+import com.yahoo.vespa.hosted.controller.api.identifiers.AthensDomain;
+import com.yahoo.vespa.hosted.controller.api.identifiers.ScrewdriverId;
+import com.yahoo.vespa.hosted.controller.api.identifiers.UserId;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ApplicationAction;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.Athens;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.AthensPrincipal;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.ZmsException;
+import com.yahoo.vespa.hosted.controller.api.integration.zone.ZoneRegistry;
+import com.yahoo.vespa.hosted.controller.restapi.filter.UnauthenticatedUserPrincipal;
+
+import javax.ws.rs.ForbiddenException;
+import java.security.Principal;
+import java.util.Optional;
+import java.util.logging.Logger;
+
+import static com.yahoo.vespa.hosted.controller.restapi.application.Authorizer.environmentRequiresAuthorization;
+import static com.yahoo.vespa.hosted.controller.restapi.application.Authorizer.isScrewdriverPrincipal;
+
+/**
+ * @author bjorncs
+ * @author gjoranv
+ */
+public class DeployAuthorizer {
+
+ private static final Logger log = Logger.getLogger(DeployAuthorizer.class.getName());
+
+ private final Athens athens;
+ private final ZoneRegistry zoneRegistry;
+
+ public DeployAuthorizer(Athens athens, ZoneRegistry zoneRegistry) {
+ this.athens = athens;
+ this.zoneRegistry = zoneRegistry;
+ }
+
+ public void throwIfUnauthorizedForDeploy(Principal principal,
+ Environment environment,
+ Tenant tenant,
+ ApplicationId applicationId) {
+ if (athensCredentialsRequired(environment, tenant, applicationId, principal))
+ checkAthensCredentials(principal, tenant, applicationId);
+ }
+
+ // TODO: inline when deployment via ssh is removed
+ private boolean athensCredentialsRequired(Environment environment, Tenant tenant, ApplicationId applicationId, Principal principal) {
+ if (!environmentRequiresAuthorization(environment)) return false;
+
+ if (! isScrewdriverPrincipal(athens, principal))
+ throw loggedForbiddenException(
+ "Principal '%s' is not a screwdriver principal, and does not have deploy access to application '%s'",
+ principal.getName(), applicationId.toShortString());
+
+ return tenant.isAthensTenant();
+ }
+
+
+ // TODO: inline when deployment via ssh is removed
+ private void checkAthensCredentials(Principal principal, Tenant tenant, ApplicationId applicationId) {
+ AthensDomain domain = tenant.getAthensDomain().get();
+ if (! (principal instanceof AthensPrincipal))
+ throw loggedForbiddenException("Principal '%s' is not authenticated.", principal.getName());
+
+ AthensPrincipal athensPrincipal = (AthensPrincipal)principal;
+ if ( ! hasDeployAccessToAthensApplication(athensPrincipal, domain, applicationId))
+ throw loggedForbiddenException(
+ "Screwdriver principal '%1$s' does not have deploy access to '%2$s'. " +
+ "Either the application has not been created at " + zoneRegistry.getDashboardUri() + " or " +
+ "'%1$s' is not added to the application's deployer role in Athens domain '%3$s'.",
+ athensPrincipal, applicationId, tenant.getAthensDomain().get());
+ }
+
+ private static ForbiddenException loggedForbiddenException(String message, Object... args) {
+ String formattedMessage = String.format(message, args);
+ log.info(formattedMessage);
+ return new ForbiddenException(formattedMessage);
+ }
+
+ /**
+ * @deprecated Only usable for ssh. Use the method that takes Principal instead of UserId and screwdriverId.
+ */
+ @Deprecated
+ public void throwIfUnauthorizedForDeploy(Environment environment,
+ UserId userId,
+ Tenant tenant,
+ ApplicationId applicationId,
+ Optional<ScrewdriverId> optionalScrewdriverId) {
+
+ Principal principal = new UnauthenticatedUserPrincipal(userId.id());
+
+ if (athensCredentialsRequired(environment, tenant, applicationId, principal)) {
+ ScrewdriverId screwdriverId = optionalScrewdriverId.orElseThrow(
+ () -> loggedForbiddenException("Screwdriver id must be provided when deploying from Screwdriver."));
+ principal = athens.principalFrom(screwdriverId);
+ checkAthensCredentials(principal, tenant, applicationId);
+ }
+ }
+
+ private boolean hasDeployAccessToAthensApplication(AthensPrincipal principal, AthensDomain domain, ApplicationId applicationId) {
+ try {
+ return athens.zmsClientFactory().createClientWithServicePrincipal()
+ .hasApplicationAccess(
+ principal,
+ ApplicationAction.deploy,
+ domain,
+ new com.yahoo.vespa.hosted.controller.api.identifiers.ApplicationId(applicationId.application().value()));
+ } catch (ZmsException e) {
+ throw loggedForbiddenException(
+ "Failed to authorize deployment through Athens. If this problem persists, " +
+ "please create ticket at yo/vespa-support. (" + e.getMessage() + ")");
+ }
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/EmptyJsonResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/EmptyJsonResponse.java
new file mode 100644
index 00000000000..3e8d4182c42
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/EmptyJsonResponse.java
@@ -0,0 +1,25 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.application;
+
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.Slime;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author bratseth
+ */
+public class EmptyJsonResponse extends HttpResponse {
+
+ public EmptyJsonResponse() {
+ super(200);
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException { }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/JacksonJsonResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/JacksonJsonResponse.java
new file mode 100644
index 00000000000..cfd6feccf01
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/JacksonJsonResponse.java
@@ -0,0 +1,31 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.application;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yahoo.container.jdisc.HttpResponse;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * @author bratseth
+ */
+public class JacksonJsonResponse extends HttpResponse {
+
+ private final JsonNode node;
+
+ public JacksonJsonResponse(JsonNode node) {
+ super(200);
+ this.node = node;
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ new ObjectMapper().writeValue(stream, node);
+ }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/MultipartParser.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/MultipartParser.java
new file mode 100644
index 00000000000..75f4ff68f1e
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/MultipartParser.java
@@ -0,0 +1,72 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.application;
+
+import com.yahoo.container.jdisc.HttpRequest;
+import org.apache.commons.fileupload.MultipartStream;
+import org.apache.commons.fileupload.ParameterParser;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Provides reading a multipart/form-data request type into a map of bytes for each part,
+ * indexed by the parts (form field) name.
+ *
+ * @author bratseth
+ */
+public class MultipartParser {
+
+ /**
+ * Parses the given multi-part request and returns all the parts indexed by their name.
+ *
+ * @throws IllegalArgumentException if this request is not a well-formed request with Content-Type multipart/form-data
+ */
+ public Map<String, byte[]> parse(HttpRequest request) {
+ try {
+ ParameterParser parameterParser = new ParameterParser();
+ Map<String, String> contentType = parameterParser.parse(request.getHeader("Content-Type"), ';');
+ if ( ! contentType.containsKey("multipart/form-data"))
+ throw new IllegalArgumentException("Expected a multipart message, but got Content-Type: " +
+ request.getHeader("Content-Type"));
+ String boundary = contentType.get("boundary");
+ if (boundary == null)
+ throw new IllegalArgumentException("Missing boundary property in Content-Type header");
+ MultipartStream multipartStream = new MultipartStream(request.getData(), boundary.getBytes(),
+ 1000 * 1000,
+ null);
+ boolean nextPart = multipartStream.skipPreamble();
+ Map<String, byte[]> parts = new HashMap<>();
+ while (nextPart) {
+ String[] headers = multipartStream.readHeaders().split("\r\n");
+ String contentDispositionContent = findContentDispositionHeader(headers);
+ if (contentDispositionContent == null)
+ throw new IllegalArgumentException("Missing Content-Disposition header in a multipart body part");
+ Map<String, String> contentDisposition = parameterParser.parse(contentDispositionContent, ';');
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ multipartStream.readBodyData(output);
+ parts.put(contentDisposition.get("name"), output.toByteArray());
+ nextPart = multipartStream.readBoundary();
+ }
+ return parts;
+ }
+ catch(MultipartStream.MalformedStreamException e) {
+ throw new IllegalArgumentException("Malformed multipart/form-data request", e);
+ }
+ catch(IOException e) {
+ throw new IllegalArgumentException("IO error reading multipart request " + request.getUri(), e);
+ }
+ }
+
+ private String findContentDispositionHeader(String[] headers) {
+ String contentDisposition = "Content-Disposition:";
+ for (String header : headers) {
+ if (header.length() < contentDisposition.length()) continue;
+ if ( ! header.substring(0, contentDisposition.length()).equalsIgnoreCase(contentDisposition)) continue;
+ return header.substring(contentDisposition.length() + 1);
+ }
+ return null;
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ServiceApiResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ServiceApiResponse.java
new file mode 100644
index 00000000000..6a448e475c5
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ServiceApiResponse.java
@@ -0,0 +1,191 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.application;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.config.provision.Zone;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.JsonFormat;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.hosted.controller.restapi.Uri;
+import com.yahoo.vespa.serviceview.bindings.ApplicationView;
+import com.yahoo.vespa.serviceview.bindings.ClusterView;
+import com.yahoo.vespa.serviceview.bindings.ServiceView;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A response containing a service view for an application deployment.
+ * This does not define the API response but merely proxies the API response provided by Vespa, with URLs
+ * rewritten to include zone and application information allow proxying through the controller
+ *
+ * @author Steinar Knutsen
+ * @author bratseth
+ */
+class ServiceApiResponse extends HttpResponse {
+
+ private final Zone zone;
+ private final ApplicationId application;
+ private final List<URI> configServerURIs;
+ private final Slime slime;
+ private final Uri requestUri;
+
+ // Only set for one of the setResponse calls
+ private String serviceName = null;
+ private String restPath = null;
+
+ public ServiceApiResponse(Zone zone, ApplicationId application, List<URI> configServerURIs, URI requestUri) {
+ super(200);
+ this.zone = zone;
+ this.application = application;
+ this.configServerURIs = configServerURIs;
+ this.slime = new Slime();
+ this.requestUri = new Uri(requestUri).withoutParameters();
+ }
+
+ public void setResponse(ApplicationView applicationView) {
+ Cursor clustersArray = slime.setObject().setArray("clusters");
+ for (ClusterView clusterView : applicationView.clusters) {
+ Cursor clusterObject = clustersArray.addObject();
+ clusterObject.setString("name", clusterView.name);
+ clusterObject.setString("type", clusterView.type);
+ setNullableString("url", rewriteIfUrl(clusterView.url, requestUri), clusterObject);
+ Cursor servicesArray = clusterObject.setArray("services");
+ for (ServiceView serviceView : clusterView.services) {
+ Cursor serviceObject = servicesArray.addObject();
+ setNullableString("url", rewriteIfUrl(serviceView.url, requestUri), serviceObject);
+ serviceObject.setString("serviceType", serviceView.serviceType);
+ serviceObject.setString("serviceName", serviceView.serviceName);
+ serviceObject.setString("configId", serviceView.configId);
+ serviceObject.setString("host", serviceView.host);
+ }
+ }
+ }
+
+ public void setResponse(Map<?,?> responseData, String serviceName, String restPath) {
+ this.serviceName = serviceName;
+ this.restPath = restPath;
+ mapToSlime(responseData, slime.setObject());
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ new JsonFormat(true).encode(stream, slime);
+ }
+
+ @SuppressWarnings("unchecked")
+ private void mapToSlime(Map<?,?> data, Cursor object) {
+ for (Map.Entry<String, Object> entry : ((Map<String, Object>)data).entrySet())
+ fieldToSlime(entry.getKey(), entry.getValue(), object);
+ }
+
+ private void fieldToSlime(String key, Object value, Cursor object) {
+ if (value instanceof String) {
+ if (key.equals("url") || key.equals("link"))
+ value = rewriteIfUrl((String)value, generateLocalLinkPrefix(serviceName, restPath));
+ setNullableString(key, (String)value, object);
+ }
+ else if (value instanceof Integer) {
+ object.setLong(key, (int)value);
+ }
+ else if (value instanceof Long) {
+ object.setLong(key, (long)value);
+ }
+ else if (value instanceof Float) {
+ object.setDouble(key, (double)value);
+ }
+ else if (value instanceof Double) {
+ object.setDouble(key, (double)value);
+ }
+ else if (value instanceof List) {
+ listToSlime((List)value, object.setArray(key));
+ }
+ else if (value instanceof Map) {
+ mapToSlime((Map<?,?>)value, object.setObject(key));
+ }
+ }
+
+ private void listToSlime(List<?> list, Cursor array) {
+ for (Object entry : list)
+ entryToSlime(entry, array);
+ }
+
+ private void entryToSlime(Object entry, Cursor array) {
+ if (entry instanceof String)
+ addNullableString(rewriteIfUrl((String)entry, generateLocalLinkPrefix(serviceName, restPath)), array);
+ else if (entry instanceof Integer)
+ array.addLong((long)entry);
+ else if (entry instanceof Long)
+ array.addLong((long)entry);
+ else if (entry instanceof Float)
+ array.addDouble((double)entry);
+ else if (entry instanceof Double)
+ array.addDouble((double)entry);
+ else if (entry instanceof List)
+ listToSlime((List)entry, array.addArray());
+ else if (entry instanceof Map)
+ mapToSlime((Map)entry, array.addObject());
+ }
+
+ private String rewriteIfUrl(String urlOrAnyString, Uri requestUri) {
+ if (urlOrAnyString == null) return null;
+
+ String hostPattern = "(" +
+ String.join(
+ "|", configServerURIs.stream()
+ .map(URI::toString)
+ .map(s -> s.substring(0, s.length() -1))
+ .map(Pattern::quote)
+ .toArray(String[]::new))
+ + ")";
+
+ String remoteServicePath = "/serviceview/"
+ + "v1/tenant/" + application.tenant().value()
+ + "/application/" + application.application().value()
+ + "/environment/" + zone.environment().value()
+ + "/region/" + zone.region().value()
+ + "/instance/" + application.instance()
+ + "/service/";
+
+ Pattern remoteServiceResourcePattern = Pattern.compile("^(" + hostPattern + Pattern.quote(remoteServicePath) + ")");
+ Matcher matcher = remoteServiceResourcePattern.matcher(urlOrAnyString);
+
+ if (matcher.find()) {
+ String proxiedPath = urlOrAnyString.substring(matcher.group().length());
+ return requestUri.append(proxiedPath).toString();
+ } else {
+ return urlOrAnyString; // not a service url
+ }
+ }
+
+ private Uri generateLocalLinkPrefix(String identifier, String restPath) {
+ String proxiedPath = identifier + "/" + restPath;
+
+ if (this.requestUri.toString().endsWith(proxiedPath)) {
+ return new Uri(this.requestUri.toString().substring(0, this.requestUri.toString().length() - proxiedPath.length()));
+ } else {
+ throw new IllegalStateException("Expected the resource path '" + this.requestUri + "' to end with '" + proxiedPath + "'");
+ }
+ }
+
+ private void setNullableString(String key, String valueOrNull, Cursor receivingObject) {
+ if (valueOrNull == null)
+ receivingObject.setNix(key);
+ else
+ receivingObject.setString(key, valueOrNull);
+ }
+
+ private void addNullableString(String valueOrNull, Cursor receivingArray) {
+ if (valueOrNull == null)
+ receivingArray.addNix();
+ else
+ receivingArray.addString(valueOrNull);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java
new file mode 100644
index 00000000000..e02a31440ce
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/ControllerApiHandler.java
@@ -0,0 +1,84 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.controller;
+
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.vespa.hosted.controller.maintenance.ControllerMaintenance;
+import com.yahoo.vespa.hosted.controller.restapi.ErrorResponse;
+import com.yahoo.vespa.hosted.controller.restapi.MessageResponse;
+import com.yahoo.vespa.hosted.controller.restapi.Path;
+import com.yahoo.vespa.hosted.controller.restapi.ResourceResponse;
+import com.yahoo.yolean.Exceptions;
+
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+
+/**
+ * This implements the controller/v1 API which provides operators with information about,
+ * and control over the Controller.
+ *
+ * @author bratseth
+ */
+public class ControllerApiHandler extends LoggingRequestHandler {
+
+ private final ControllerMaintenance maintenance;
+
+ public ControllerApiHandler(Executor executor, AccessLog accessLog, ControllerMaintenance maintenance) {
+ super(executor, accessLog);
+ this.maintenance = maintenance;
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest request) {
+ try {
+ switch (request.getMethod()) {
+ case GET: return handleGET(request);
+ case POST: return handlePOST(request);
+ case DELETE: return handleDELETE(request);
+ default: return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported");
+ }
+ }
+ catch (IllegalArgumentException e) {
+ return ErrorResponse.badRequest(Exceptions.toMessageString(e));
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Unexpected error handling '" + request.getUri() + "'", e);
+ return ErrorResponse.internalServerError(Exceptions.toMessageString(e));
+ }
+ }
+
+ private HttpResponse handleGET(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/controller/v1/")) return root(request);
+ if (path.matches("/controller/v1/maintenance/")) return new JobsResponse(maintenance.jobControl());
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handlePOST(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/controller/v1/maintenance/inactive/{jobName}"))
+ return setActive(path.get("jobName"), false);
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handleDELETE(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/controller/v1/maintenance/inactive/{jobName}"))
+ return setActive(path.get("jobName"), true);
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse root(HttpRequest request) {
+ return new ResourceResponse(request, "maintenance");
+ }
+
+ private HttpResponse setActive(String jobName, boolean active) {
+ if ( ! maintenance.jobControl().jobs().contains(jobName))
+ return ErrorResponse.notFoundError("No job named '" + jobName + "'");
+ maintenance.jobControl().setActive(jobName, active);
+ return new MessageResponse((active ? "Re-activated" : "Deactivated" ) + " job '" + jobName + "'");
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/JobsResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/JobsResponse.java
new file mode 100644
index 00000000000..e7d1b3e0ed8
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/controller/JobsResponse.java
@@ -0,0 +1,46 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.controller;
+
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.JsonFormat;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.hosted.controller.maintenance.JobControl;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * A response containing maintenance job status
+ *
+ * @author bratseth
+ */
+public class JobsResponse extends HttpResponse {
+
+ private final JobControl jobControl;
+
+ public JobsResponse(JobControl jobControl) {
+ super(200);
+ this.jobControl = jobControl;
+ }
+
+ @Override
+ public void render(OutputStream stream) throws IOException {
+ Slime slime = new Slime();
+ Cursor root = slime.setObject();
+
+ Cursor jobArray = root.setArray("jobs");
+ for (String jobName : jobControl.jobs())
+ jobArray.addObject().setString("name", jobName);
+
+ Cursor inactiveArray = root.setArray("inactive");
+ for (String jobName : jobControl.inactiveJobs())
+ inactiveArray.addString(jobName);
+
+ new JsonFormat(true).encode(stream, slime);
+ }
+
+ @Override
+ public String getContentType() { return "application/json"; }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/DeploymentApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/DeploymentApiHandler.java
new file mode 100644
index 00000000000..affd679f2c2
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/deployment/DeploymentApiHandler.java
@@ -0,0 +1,122 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.deployment;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.hosted.controller.Application;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.versions.VespaVersion;
+import com.yahoo.vespa.hosted.controller.restapi.ErrorResponse;
+import com.yahoo.vespa.hosted.controller.restapi.SlimeJsonResponse;
+import com.yahoo.vespa.hosted.controller.restapi.Uri;
+import com.yahoo.vespa.hosted.controller.restapi.application.EmptyJsonResponse;
+import com.yahoo.vespa.hosted.controller.restapi.Path;
+import com.yahoo.yolean.Exceptions;
+
+import java.time.Instant;
+import java.util.Optional;
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+
+/**
+ * This implements the deployment/v1 API which provides information about the status of Vespa platform and
+ * application deployments.
+ *
+ * @author bratseth
+ */
+public class DeploymentApiHandler extends LoggingRequestHandler {
+
+ private final Controller controller;
+
+ public DeploymentApiHandler(Executor executor, AccessLog accessLog, Controller controller) {
+ super(executor, accessLog);
+ this.controller = controller;
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest request) {
+ try {
+ switch (request.getMethod()) {
+ case GET: return handleGET(request);
+ case OPTIONS: return handleOPTIONS();
+ default: return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported");
+ }
+ }
+ catch (IllegalArgumentException e) {
+ return ErrorResponse.badRequest(Exceptions.toMessageString(e));
+ }
+ catch (RuntimeException e) {
+ log.log(Level.WARNING, "Unexpected error handling '" + request.getUri() + "'", e);
+ return ErrorResponse.internalServerError(Exceptions.toMessageString(e));
+ }
+ }
+
+ private HttpResponse handleGET(HttpRequest request) {
+ Path path = new Path(request.getUri().getPath());
+ if (path.matches("/deployment/v1/")) return root(request);
+ return ErrorResponse.notFoundError("Nothing at " + path);
+ }
+
+ private HttpResponse handleOPTIONS() {
+ // We implement this to avoid redirect loops on OPTIONS requests from browsers, but do not really bother
+ // spelling out the methods supported at each path, which we should
+ EmptyJsonResponse response = new EmptyJsonResponse();
+ response.headers().put("Allow", "GET,OPTIONS");
+ return response;
+ }
+
+ private HttpResponse root(HttpRequest request) {
+ Slime slime = new Slime();
+ Cursor root = slime.setObject();
+ Cursor platformArray = root.setArray("versions");
+ for (VespaVersion version : controller.versionStatus().versions()) {
+ Cursor versionObject = platformArray.addObject();
+ versionObject.setString("version", version.versionNumber().toString());
+ versionObject.setString("confidence", version.confidence().name());
+ versionObject.setString("commit", version.releaseCommit());
+ versionObject.setLong("date", version.releasedAt().toEpochMilli());
+ versionObject.setBool("controllerVersion", version.isSelfVersion());
+ versionObject.setBool("systemVersion", version.isCurrentSystemVersion());
+
+ Cursor configServerArray = versionObject.setArray("configServers");
+ for (String configServerHostnames : version.configServerHostnames()) {
+ Cursor configServerObject = configServerArray.addObject();
+ configServerObject.setString("hostname", configServerHostnames);
+ }
+
+ Cursor failingArray = versionObject.setArray("failingApplications");
+ for (ApplicationId id : version.statistics().failing()) {
+ Optional<Application> application = controller.applications().get(id);
+ if ( ! application.isPresent()) continue; // deleted just now
+
+ Instant failingSince = application.get().deploymentJobs().failingSince();
+ if (failingSince == null) continue; // started working just now
+
+ Cursor applicationObject = failingArray.addObject();
+ toSlime(id, applicationObject, request);
+ applicationObject.setLong("failingSince", failingSince.toEpochMilli());
+ }
+
+ Cursor productionArray = versionObject.setArray("productionApplications");
+ for (ApplicationId id : version.statistics().production())
+ toSlime(id, productionArray.addObject(), request);
+ }
+ return new SlimeJsonResponse(slime);
+ }
+
+ private void toSlime(ApplicationId id, Cursor object, HttpRequest request) {
+ object.setString("tenant", id.tenant().value());
+ object.setString("application", id.application().value());
+ object.setString("instance", id.instance().value());
+ object.setString("url", new Uri(request.getUri()).withPath("/application/v4" +
+ "/tenant/" + id.tenant().value() +
+ "/application/" + id.application().value())
+ .toString());
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlHeaders.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlHeaders.java
new file mode 100644
index 00000000000..aea59c16cd5
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlHeaders.java
@@ -0,0 +1,26 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter;
+
+import com.google.common.collect.ImmutableMap;
+
+import java.util.Map;
+
+import static java.util.concurrent.TimeUnit.DAYS;
+
+/**
+ * @author gv
+ */
+public interface AccessControlHeaders {
+
+ String CORS_PREFLIGHT_REQUEST_CACHE_TTL = Long.toString(DAYS.toSeconds(7));
+
+ String ALLOW_ORIGIN_HEADER = "Access-Control-Allow-Origin";
+
+ Map<String, String> ACCESS_CONTROL_HEADERS = ImmutableMap.of(
+ "Access-Control-Max-Age", CORS_PREFLIGHT_REQUEST_CACHE_TTL,
+ "Access-Control-Allow-Headers", "Origin,Content-Type,Accept,Yahoo-Principal-Auth",
+ "Access-Control-Allow-Methods", "OPTIONS,GET,PUT,DELETE,POST",
+ "Access-Control-Allow-Credentials", "true"
+ );
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlRequestFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlRequestFilter.java
new file mode 100644
index 00000000000..8dace5d56dc
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlRequestFilter.java
@@ -0,0 +1,68 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter;
+
+import com.google.inject.Inject;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpResponse;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+import com.yahoo.vespa.hosted.controller.restapi.filter.config.HttpAccessControlConfig;
+import com.yahoo.yolean.chain.After;
+import com.yahoo.yolean.chain.Before;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static com.yahoo.jdisc.http.HttpRequest.Method.OPTIONS;
+import static com.yahoo.vespa.hosted.controller.restapi.filter.AccessControlHeaders.ACCESS_CONTROL_HEADERS;
+import static com.yahoo.vespa.hosted.controller.restapi.filter.AccessControlHeaders.ALLOW_ORIGIN_HEADER;
+
+/**
+ * <p>
+ * This filter makes sure we respond as quickly as possible to CORS pre-flight requests
+ * which browsers transmit before the Hosted Vespa dashboard code is allowed to send a "real" request.
+ * </p>
+ * <p>
+ * An "Access-Control-Max-Age" header is added so that the browser will cache the result of this pre-flight request,
+ * further improving the responsiveness of the Hosted Vespa dashboard application.
+ * </p>
+ * <p>
+ * Runs after all standard security request filters, but before BouncerFilter, as the browser does not send
+ * credentials with pre-flight requests.
+ * </p>
+ *
+ * @author andreer
+ * @author gv
+ */
+@After({"InputValidationFilter","RemoteIPFilter", "DoNotTrackRequestFilter", "CookieDataRequestFilter"})
+@Before("BouncerFilter")
+public class AccessControlRequestFilter implements SecurityRequestFilter {
+ private final Set<String> allowedUrls;
+
+ @Inject
+ public AccessControlRequestFilter(HttpAccessControlConfig config) {
+ allowedUrls = Collections.unmodifiableSet(config.allowedUrls().stream().collect(Collectors.toSet()));
+ }
+
+ @Override
+ public void filter(DiscFilterRequest discFilterRequest, ResponseHandler responseHandler) {
+ String origin = discFilterRequest.getHeader("Origin");
+
+ if (!discFilterRequest.getMethod().equals(OPTIONS.name()))
+ return;
+
+ HttpResponse response = HttpResponse.newInstance(Response.Status.OK);
+
+ if (allowedUrls.contains(origin))
+ response.headers().add(ALLOW_ORIGIN_HEADER, origin);
+
+ ACCESS_CONTROL_HEADERS.forEach(
+ (name, value) -> response.headers().add(name, value));
+
+ ContentChannel cc = responseHandler.handleResponse(response);
+ cc.close(null);
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlResponseFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlResponseFilter.java
new file mode 100644
index 00000000000..c2ad31cd925
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/AccessControlResponseFilter.java
@@ -0,0 +1,55 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter;
+
+import com.yahoo.jdisc.AbstractResource;
+import com.yahoo.jdisc.http.filter.DiscFilterResponse;
+import com.yahoo.jdisc.http.filter.RequestView;
+import com.yahoo.jdisc.http.filter.SecurityResponseFilter;
+import com.yahoo.vespa.hosted.controller.restapi.filter.config.HttpAccessControlConfig;
+
+import java.util.List;
+import java.util.Optional;
+
+import static com.yahoo.vespa.hosted.controller.restapi.filter.AccessControlHeaders.ACCESS_CONTROL_HEADERS;
+import static com.yahoo.vespa.hosted.controller.restapi.filter.AccessControlHeaders.ALLOW_ORIGIN_HEADER;
+
+/**
+ * @author gv
+ * @author Tony Vaagenes
+ */
+public class AccessControlResponseFilter extends AbstractResource implements SecurityResponseFilter {
+
+ private final List<String> allowedUrls;
+
+ public AccessControlResponseFilter(HttpAccessControlConfig config) {
+ allowedUrls = config.allowedUrls();
+ }
+
+ @Override
+ public void filter(DiscFilterResponse response, RequestView request) {
+ Optional<String> requestOrigin = request.getFirstHeader("Origin");
+
+ requestOrigin.ifPresent(
+ origin -> allowedUrls.stream()
+ .filter(allowedUrl -> matchesRequestOrigin(origin, allowedUrl))
+ .findAny()
+ .ifPresent(allowedOrigin -> setHeaderUnlessExists(response, ALLOW_ORIGIN_HEADER, allowedOrigin))
+ );
+ ACCESS_CONTROL_HEADERS.forEach((name, value) -> setHeaderUnlessExists(response, name, value));
+ }
+
+ private boolean matchesRequestOrigin(String requestOrigin, String allowedUrl) {
+ return allowedUrl.equals("*") || requestOrigin.startsWith(allowedUrl);
+ }
+
+ /**
+ * This is to avoid duplicating headers already set by the {@link AccessControlRequestFilter}.
+ * Currently (March 2016), this filter is invoked for OPTIONS requests to jdisc request handlers,
+ * even if the request filter has been invoked first. For jersey based APIs, this filter is NOT
+ * invoked in these cases.
+ */
+ private void setHeaderUnlessExists(DiscFilterResponse response, String name, String value) {
+ if (response.getHeader(name) == null)
+ response.setHeader(name, value);
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/DummyFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/DummyFilter.java
new file mode 100644
index 00000000000..7beb3f755ad
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/DummyFilter.java
@@ -0,0 +1,16 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter;
+
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+
+/**
+ * @author Stian Kristoffersen
+ */
+public class DummyFilter implements SecurityRequestFilter {
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler handler) {
+ /* Do nothing - a bug in JDisc prevents empty request chains */
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/NTokenRequestFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/NTokenRequestFilter.java
new file mode 100644
index 00000000000..0138d3ae65c
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/NTokenRequestFilter.java
@@ -0,0 +1,33 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter;
+
+import com.google.inject.Inject;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+import com.yahoo.vespa.hosted.controller.api.integration.athens.Athens;
+import com.yahoo.yolean.chain.After;
+
+/**
+ * @author bjorncs
+ */
+@After("BouncerFilter")
+public class NTokenRequestFilter implements SecurityRequestFilter {
+
+ public static final String NTOKEN_HEADER = "com.yahoo.vespa.hosted.controller.restapi.filter.NTokenRequestFilter.ntoken";
+
+ private final Athens athens;
+
+ @Inject
+ public NTokenRequestFilter(Athens athens) {
+ this.athens = athens;
+ }
+
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler responseHandler) {
+ String nToken = request.getHeader(athens.principalTokenHeader());
+ if (nToken != null) {
+ request.setAttribute(NTOKEN_HEADER, nToken);
+ }
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SetBouncerPassthruHeaderFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SetBouncerPassthruHeaderFilter.java
new file mode 100644
index 00000000000..7ea98528a88
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/SetBouncerPassthruHeaderFilter.java
@@ -0,0 +1,27 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter;
+
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+import com.yahoo.yolean.chain.After;
+
+/**
+ * @author Stian Kristoffersen
+ */
+@After("BouncerFilter")
+public class SetBouncerPassthruHeaderFilter implements SecurityRequestFilter {
+
+ public static final String BOUNCER_PASSTHRU_ATTRIBUTE = "bouncer.bypassthru";
+ public static final String BOUNCER_PASSTHRU_COOKIE_OK = "1";
+ public static final String BOUNCER_PASSTHRU_HEADER_FIELD = "com.yahoo.hosted.vespa.bouncer.passthru";
+
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler handler) {
+ Object statusProperty = request.getAttribute(BOUNCER_PASSTHRU_ATTRIBUTE);
+ String status = Integer.toString((int)statusProperty);
+
+ request.addHeader(BOUNCER_PASSTHRU_HEADER_FIELD, status);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UnauthenticatedUserPrincipal.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UnauthenticatedUserPrincipal.java
new file mode 100644
index 00000000000..a88e881ce9d
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UnauthenticatedUserPrincipal.java
@@ -0,0 +1,44 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter;
+
+import java.security.Principal;
+import java.util.Objects;
+
+/**
+ * A principal for an unauthenticated user (typically from a trusted host).
+ * This principal should only be used in combination with machine authentication!
+ *
+ * @author bjorncs
+ */
+public class UnauthenticatedUserPrincipal implements Principal {
+ private final String username;
+
+ public UnauthenticatedUserPrincipal(String username) {
+ this.username = username;
+ }
+
+ @Override
+ public String getName() {
+ return username;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ UnauthenticatedUserPrincipal that = (UnauthenticatedUserPrincipal) o;
+ return Objects.equals(username, that.username);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(username);
+ }
+
+ @Override
+ public String toString() {
+ return "UnauthenticatedUserPrincipal{" +
+ "username='" + username + '\'' +
+ '}';
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UserIdRequestFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UserIdRequestFilter.java
new file mode 100644
index 00000000000..46df4d7a603
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/UserIdRequestFilter.java
@@ -0,0 +1,23 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter;
+
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+import com.yahoo.vespa.hosted.controller.api.nonpublic.HeaderFields;
+import com.yahoo.yolean.chain.Before;
+
+/**
+ * Allows hosts using host-based authentication to set user ID.
+ *
+ * @author Tony Vaagenes
+ */
+@Before("CreateSecurityContextFilter")
+public class UserIdRequestFilter implements SecurityRequestFilter {
+
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler handler) {
+ String userName = request.getHeader(HeaderFields.USER_ID_HEADER_FIELD);
+ request.setUserPrincipal(new UnauthenticatedUserPrincipal(userName));
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/CreateSecurityContextFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/CreateSecurityContextFilter.java
new file mode 100644
index 00000000000..850130ca970
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/CreateSecurityContextFilter.java
@@ -0,0 +1,50 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter.securitycontext;
+
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.DiscFilterRequest;
+import com.yahoo.jdisc.http.filter.SecurityRequestFilter;
+import com.yahoo.vespa.hosted.controller.common.ContextAttributes;
+import com.yahoo.yolean.chain.After;
+import com.yahoo.yolean.chain.Provides;
+
+import javax.ws.rs.core.SecurityContext;
+import java.security.Principal;
+
+/**
+ * Exposes the security information from the disc filter request
+ * by storing a security context in the request context.
+ *
+ * @author Tony Vaagenes
+ */
+@After("BouncerFilter")
+@Provides("SecurityContext")
+public class CreateSecurityContextFilter implements SecurityRequestFilter {
+
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler handler) {
+ request.setAttribute(ContextAttributes.SECURITY_CONTEXT_ATTRIBUTE,
+ new SecurityContext() {
+ @Override
+ public Principal getUserPrincipal() {
+ return request.getUserPrincipal();
+ }
+
+ @Override
+ public boolean isUserInRole(String role) {
+ return request.isUserInRole(role);
+ }
+
+ @Override
+ public boolean isSecure() {
+ return request.isSecure();
+ }
+
+ @Override
+ public String getAuthenticationScheme() {
+ throw new UnsupportedOperationException();
+ }
+ });
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/PropagateSecurityContextFilter.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/PropagateSecurityContextFilter.java
new file mode 100644
index 00000000000..17c86e89362
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/PropagateSecurityContextFilter.java
@@ -0,0 +1,31 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.filter.securitycontext;
+
+import com.yahoo.vespa.hosted.controller.common.ContextAttributes;
+
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerRequestFilter;
+import javax.ws.rs.container.PreMatching;
+import javax.ws.rs.core.SecurityContext;
+import javax.ws.rs.ext.Provider;
+import java.io.IOException;
+
+/**
+ * Get the security context from the underlying Servlet request, and expose it to
+ * Jersey resources.
+ *
+ * @author Tony Vaagenes
+ */
+@PreMatching
+@Provider
+public class PropagateSecurityContextFilter implements ContainerRequestFilter {
+ @Override
+ public void filter(ContainerRequestContext requestContext) throws IOException {
+ SecurityContext securityContext =
+ (SecurityContext) requestContext.getProperty(ContextAttributes.SECURITY_CONTEXT_ATTRIBUTE);
+
+ if (securityContext != null) {
+ requestContext.setSecurityContext(securityContext);
+ }
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/package-info.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/package-info.java
new file mode 100644
index 00000000000..0b98599dbb0
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/filter/securitycontext/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * Jersey requires that the package is exported to be able to instantiate the filter.
+ *
+ * @author Tony Vaagenes
+ */
+@ExportPackage
+package com.yahoo.vespa.hosted.controller.restapi.filter.securitycontext;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/screwdriver/ScrewdriverApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/screwdriver/ScrewdriverApiHandler.java
new file mode 100644
index 00000000000..a623e880c4c
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/screwdriver/ScrewdriverApiHandler.java
@@ -0,0 +1,168 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.screwdriver;
+
+import com.yahoo.config.provision.ApplicationId;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.io.IOUtils;
+import com.yahoo.jdisc.http.HttpRequest.Method;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Inspector;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.config.SlimeUtils;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.integration.BuildService.BuildJob;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobError;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobReport;
+import com.yahoo.vespa.hosted.controller.application.DeploymentJobs.JobType;
+import com.yahoo.vespa.hosted.controller.restapi.ErrorResponse;
+import com.yahoo.vespa.hosted.controller.restapi.SlimeJsonResponse;
+import com.yahoo.vespa.hosted.controller.restapi.StringResponse;
+import com.yahoo.vespa.hosted.controller.versions.VespaVersion;
+import com.yahoo.yolean.Exceptions;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * This implements a callback API from Screwdriver which lets deployment jobs notify the controller
+ * on completion.
+ *
+ * @author bratseth
+ */
+public class ScrewdriverApiHandler extends LoggingRequestHandler {
+
+ private final static Logger log = Logger.getLogger(ScrewdriverApiHandler.class.getName());
+
+ private final Controller controller;
+ // TODO: Remember to distinguish between PR jobs and component ones, by adding reports to the right jobs?
+
+ public ScrewdriverApiHandler(Executor executor, AccessLog accessLog, Controller controller) {
+ super(executor, accessLog);
+ this.controller = controller;
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest request) {
+ try {
+ Method method = request.getMethod();
+ String path = request.getUri().getPath();
+ switch (method) {
+ case GET: switch (path) {
+ case "/screwdriver/v1/release/vespa": return vespaVersion();
+ case "/screwdriver/v1/jobsToRun": return buildJobResponse(controller.applications().deploymentTrigger().buildSystem().jobs());
+ default: return ErrorResponse.notFoundError(String.format( "No '%s' handler at '%s'", method, path));
+ }
+ case POST: switch (path) {
+ case "/screwdriver/v1/jobreport": return handleJobReportPost(request);
+ default: return ErrorResponse.notFoundError(String.format( "No '%s' handler at '%s'", method, path));
+ }
+ case DELETE: switch (path) {
+ case "/screwdriver/v1/jobsToRun": return buildJobResponse(controller.applications().deploymentTrigger().buildSystem().takeJobsToRun());
+ default: return ErrorResponse.notFoundError(String.format( "No '%s' handler at '%s'", method, path));
+ }
+ default: return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is not supported");
+ }
+ } catch (IllegalArgumentException|IllegalStateException e) {
+ return ErrorResponse.badRequest(Exceptions.toMessageString(e));
+ } catch (RuntimeException e) {
+ log.log(Level.WARNING, "Unexpected error handling '" + request.getUri() + "'", e);
+ return ErrorResponse.internalServerError(Exceptions.toMessageString(e));
+ }
+ }
+
+ private HttpResponse vespaVersion() {
+ VespaVersion version = controller.versionStatus().version(controller.systemVersion());
+ if (version == null)
+ return ErrorResponse.notFoundError("Information about the current system version is not available at this time");
+
+ Slime slime = new Slime();
+ Cursor cursor = slime.setObject();
+ cursor.setString("version", version.versionNumber().toString());
+ cursor.setString("sha", version.releaseCommit());
+ cursor.setLong("date", version.releasedAt().toEpochMilli());
+ return new SlimeJsonResponse(slime);
+
+ }
+
+ private HttpResponse buildJobResponse(List<BuildJob> buildJobs) {
+ Slime slime = new Slime();
+ Cursor buildJobArray = slime.setArray();
+ for (BuildJob buildJob : buildJobs) {
+ Cursor buildJobObject = buildJobArray.addObject();
+ buildJobObject.setLong("projectId", buildJob.projectId());
+ buildJobObject.setString("jobName", buildJob.jobName());
+ }
+ return new SlimeJsonResponse(slime);
+ }
+
+ /**
+ * Parse a JSON blob of the form:
+ * {
+ * "tenant" : String
+ * "application" : String
+ * "instance" : String
+ * "jobName" : String
+ * "projectId" : long
+ * "buildNumber" : long
+ * "success" : boolean
+ * "selfTriggering": boolean
+ * "gitChanges" : boolean
+ * "vespaVersion" : String
+ * }
+ * and notify the controller of the report.
+ *
+ * @param request The JSON blob.
+ * @return 200
+ */
+ private HttpResponse handleJobReportPost(HttpRequest request) {
+ // TODO: buildNumber is unused now -- remove, or use.
+ // TODO: selfTriggering is unused now -- remove, or use.
+ // TODO: gitChanges is unused now -- remove, or use.
+ // Note: gitChanges is probably only useful for the component step, since it check the gir repo directly;
+ // for other jobs, the last component's git commit is what matters.
+ // TODO: ApplicationId (tenant, application, instance) is unused now -- remove, or use.
+
+ controller.applications().notifyJobCompletion(toJobReport(toSlime(request.getData()).get()));
+
+ return new StringResponse("ok");
+ }
+
+ private Slime toSlime(InputStream jsonStream) {
+ try {
+ byte[] jsonBytes = IOUtils.readBytes(jsonStream, 1000 * 1000);
+ return SlimeUtils.jsonToSlime(jsonBytes);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private JobReport toJobReport(Inspector report) {
+ Optional<JobError> jobError = Optional.empty();
+ if (report.field("jobError").valid()) {
+ jobError = Optional.of(JobError.valueOf(report.field("jobError").asString()));
+ } else if (report.field("success").valid()) { // TODO: Remove after May 2017
+ jobError = JobError.from(report.field("success").asBool());
+ }
+ return new JobReport(
+ ApplicationId.from(
+ report.field("tenant").asString(),
+ report.field("application").asString(),
+ report.field("instance").asString()),
+ JobType.fromId(report.field("jobName").asString()),
+ report.field("projectId").asLong(),
+ report.field("buildNumber").asLong(),
+ jobError,
+ report.field("selfTriggering").asBool(),
+ report.field("gitChanges").asBool()
+ );
+ }
+
+}