diff options
Diffstat (limited to 'controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi')
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() + ); + } + +} |