diff options
12 files changed, 348 insertions, 1 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/configserver/ConfigServer.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/configserver/ConfigServer.java index f8f63df40a9..600a5cebc62 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/configserver/ConfigServer.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/configserver/ConfigServer.java @@ -65,6 +65,7 @@ public interface ConfigServer { * @param endpoint The endpoint to modify * @param status The new status with metadata */ + // TODO(mpolden): Implement a zone-variant of this void setGlobalRotationStatus(DeploymentId deployment, String endpoint, EndpointStatus status); /** diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java index bdc1c6e9794..998af030b6b 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java @@ -30,7 +30,8 @@ enum PathGroup { "/orchestrator/v1/{*}", "/os/v1/{*}", "/provision/v2/{*}", - "/zone/v2/{*}"), + "/zone/v2/{*}", + "/routing/v1/{*}"), /** Paths used for creating and reading user resources. */ user(Optional.of("/api"), diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/routing/RoutingApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/routing/RoutingApiHandler.java new file mode 100644 index 00000000000..a62296b11e9 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/routing/RoutingApiHandler.java @@ -0,0 +1,172 @@ +// Copyright 2020 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.restapi.routing; + +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.zone.ZoneId; +import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.restapi.ErrorResponse; +import com.yahoo.restapi.MessageResponse; +import com.yahoo.restapi.Path; +import com.yahoo.restapi.SlimeJsonResponse; +import com.yahoo.slime.Cursor; +import com.yahoo.slime.Slime; +import com.yahoo.vespa.hosted.controller.Controller; +import com.yahoo.vespa.hosted.controller.api.identifiers.DeploymentId; +import com.yahoo.vespa.hosted.controller.auditlog.AuditLoggingRequestHandler; +import com.yahoo.vespa.hosted.controller.routing.GlobalRouting; +import com.yahoo.vespa.hosted.controller.routing.RoutingPolicy; +import com.yahoo.vespa.hosted.controller.routing.RoutingPolicyId; +import com.yahoo.vespa.hosted.controller.routing.ZoneRoutingPolicy; +import com.yahoo.yolean.Exceptions; + +import java.util.Map; +import java.util.Objects; +import java.util.logging.Level; + +/** + * This implements the /routing/v1 API, which provides operator with global routing control at both zone- and + * deployment-level. + * + * @author mpolden + */ +// TODO(mpolden): Add support for zones/deployments using rotations. +public class RoutingApiHandler extends AuditLoggingRequestHandler { + + private final Controller controller; + + public RoutingApiHandler(Context ctx, Controller controller) { + super(ctx, controller.auditLogger()); + this.controller = Objects.requireNonNull(controller, "controller must be non-null"); + } + + @Override + public HttpResponse auditAndHandle(HttpRequest request) { + try { + var path = new Path(request.getUri()); + switch (request.getMethod()) { + case GET: return get(path); + case POST: return post(path); + case DELETE: return delete(path); + 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 delete(Path path) { + if (path.matches("/routing/v1/inactive/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}")) return setDeploymentStatus(path, true); + if (path.matches("/routing/v1/inactive/environment/{environment}/region/{region}")) return setZoneStatus(path, true); + return ErrorResponse.notFoundError("Nothing at " + path); + } + + private HttpResponse post(Path path) { + if (path.matches("/routing/v1/inactive/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}")) return setDeploymentStatus(path, false); + if (path.matches("/routing/v1/inactive/environment/{environment}/region/{region}")) return setZoneStatus(path, false); + return ErrorResponse.notFoundError("Nothing at " + path); + } + + private HttpResponse get(Path path) { + if (path.matches("/routing/v1/status/tenant/{tenant}/application/{application}/instance/{instance}/environment/{environment}/region/{region}")) return deploymentStatus(path); + if (path.matches("/routing/v1/status/environment/{environment}/region/{region}")) return zoneStatus(path); + return ErrorResponse.notFoundError("Nothing at " + path); + } + + private HttpResponse setZoneStatus(Path path, boolean in) { + var zone = zoneFrom(path); + var status = in ? GlobalRouting.Status.in : GlobalRouting.Status.out; + controller.applications().routingPolicies().setGlobalRoutingStatus(zone, status); + return new MessageResponse("Set global routing status for deployments in " + zone + " to '" + + (in ? "in" : "out") + "'"); + } + + private HttpResponse zoneStatus(Path path) { + var zone = zoneFrom(path); + var slime = new Slime(); + var root = slime.setObject(); + var zonePolicy = controller.applications().routingPolicies().get(zone); + zoneStatusToSlime(root, zonePolicy); + return new SlimeJsonResponse(slime); + } + + private HttpResponse setDeploymentStatus(Path path, boolean in) { + var deployment = deploymentFrom(path); + routingPoliciesOf(deployment); + var status = in ? GlobalRouting.Status.in : GlobalRouting.Status.out; + var agent = GlobalRouting.Agent.operator; // Always operator as this is an operator API + controller.applications().routingPolicies().setGlobalRoutingStatus(deployment, status, agent); + return new MessageResponse("Set global routing status for " + deployment + " to '" + (in ? "in" : "out") + "'"); + } + + private HttpResponse deploymentStatus(Path path) { + var deployment = deploymentFrom(path); + var slime = new Slime(); + var deploymentsObject = slime.setObject().setArray("deployments"); + var routingPolicies = routingPoliciesOf(deployment); + for (var policy : routingPolicies.values()) { + deploymentStatusToSlime(deploymentsObject.addObject(), policy); + } + return new SlimeJsonResponse(slime); + } + + private Map<RoutingPolicyId, RoutingPolicy> routingPoliciesOf(DeploymentId deployment) { + var routingPolicies = controller.applications().routingPolicies().get(deployment); + if (routingPolicies.isEmpty()) { + throw new IllegalArgumentException("No such deployment: " + deployment); + } + return routingPolicies; + } + + private static void zoneStatusToSlime(Cursor object, ZoneRoutingPolicy policy) { + object.setString("environment", policy.zone().environment().value()); + object.setString("region", policy.zone().region().value()); + object.setString("status", asString(policy.globalRouting().status())); + object.setString("agent", asString(policy.globalRouting().agent())); + object.setLong("changedAt", policy.globalRouting().changedAt().toEpochMilli()); + } + + private static void deploymentStatusToSlime(Cursor object, RoutingPolicy policy) { + object.setString("instance", policy.id().owner().serializedForm()); + object.setString("cluster", policy.id().cluster().value()); + object.setString("environment", policy.id().zone().environment().value()); + object.setString("region", policy.id().zone().region().value()); + object.setString("status", asString(policy.status().globalRouting().status())); + object.setString("agent", asString(policy.status().globalRouting().agent())); + object.setLong("changedAt", policy.status().globalRouting().changedAt().toEpochMilli()); + } + + private DeploymentId deploymentFrom(Path path) { + return new DeploymentId(ApplicationId.from(path.get("tenant"), path.get("application"), path.get("instance")), + zoneFrom(path)); + } + + private ZoneId zoneFrom(Path path) { + var zone = ZoneId.from(path.get("environment"), path.get("region")); + if (!controller.zoneRegistry().zones().all().ids().contains(zone)) { + throw new IllegalArgumentException("No such zone: " + zone); + } + return zone; + } + + private static String asString(GlobalRouting.Status status) { + switch (status) { + case in: return "in"; + case out: return "out"; + default: return "unknown"; + } + } + + private static String asString(GlobalRouting.Agent status) { + switch (status) { + case operator: return "operator"; + case system: return "system"; + case tenant: return "tenant"; + default: return "unknown"; + } + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicies.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicies.java index c05152e7795..88f746a9c51 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicies.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/routing/RoutingPolicies.java @@ -68,6 +68,11 @@ public class RoutingPolicies { .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } + /** Read routing policy for given zone */ + public ZoneRoutingPolicy get(ZoneId zone) { + return db.readZoneRoutingPolicy(zone); + } + /** * Refresh routing policies for application in given zone. This is idempotent and changes will only be performed if * load balancers for given application have changed. diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java index f3c437c76a1..b7adc3064fa 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java @@ -111,6 +111,9 @@ public class ControllerContainerTest { " <binding>http://*/user/v1/*</binding>\n" + " <binding>http://*/api/user/v1/*</binding>\n" + " </handler>\n" + + " <handler id='com.yahoo.vespa.hosted.controller.restapi.routing.RoutingApiHandler'>\n" + + " <binding>http://*/routing/v1/*</binding>\n" + + " </handler>\n" + variablePartXml() + "</container>"; } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/RoutingApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/RoutingApiTest.java new file mode 100644 index 00000000000..4339382432b --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/RoutingApiTest.java @@ -0,0 +1,105 @@ +// Copyright 2020 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.restapi.routing; + +import com.yahoo.application.container.handler.Request; +import com.yahoo.config.provision.zone.ZoneId; +import com.yahoo.vespa.hosted.controller.ControllerTester; +import com.yahoo.vespa.hosted.controller.deployment.ApplicationPackageBuilder; +import com.yahoo.vespa.hosted.controller.deployment.DeploymentTester; +import com.yahoo.vespa.hosted.controller.restapi.ContainerTester; +import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerTest; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; + +/** + * @author mpolden + */ +public class RoutingApiTest extends ControllerContainerTest { + + private static final String responseFiles = "src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/"; + + private ContainerTester tester; + private DeploymentTester deploymentTester; + + @Before + public void before() { + tester = new ContainerTester(container, responseFiles); + deploymentTester = new DeploymentTester(new ControllerTester(tester)); + } + + @Test + public void requests() { + var context = deploymentTester.newDeploymentContext(); + + // Deploy application + var westZone = ZoneId.from("prod", "us-west-1"); + var eastZone = ZoneId.from("prod", "us-east-3"); + var applicationPackage = new ApplicationPackageBuilder() + .region(westZone.region()) + .region(eastZone.region()) + .build(); + context.submit(applicationPackage).deploy(); + context.addRoutingPolicy(westZone, true); + context.addRoutingPolicy(eastZone, true); + + // GET initial deployment status + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/status/tenant/tenant/application/application/instance/default/environment/prod/region/us-west-1", + "", Request.Method.GET), + new File("deployment-status-initial.json")); + + // POST sets deployment out + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/inactive/tenant/tenant/application/application/instance/default/environment/prod/region/us-west-1", + "", Request.Method.POST), + "{\"message\":\"Set global routing status for tenant.application in prod.us-west-1 to 'out'\"}"); + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/status/tenant/tenant/application/application/instance/default/environment/prod/region/us-west-1", + "", Request.Method.GET), + new File("deployment-status-out.json")); + + // DELETE sets deployment in + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/inactive/tenant/tenant/application/application/instance/default/environment/prod/region/us-west-1", + "", Request.Method.DELETE), + "{\"message\":\"Set global routing status for tenant.application in prod.us-west-1 to 'in'\"}"); + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/status/tenant/tenant/application/application/instance/default/environment/prod/region/us-west-1", + "", Request.Method.GET), + new File("deployment-status-in.json")); + + // GET initial zone status + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/status/environment/prod/region/us-west-1", + "", Request.Method.GET), + new File("zone-status-initial.json")); + + // POST sets zone out + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/inactive/environment/prod/region/us-west-1", + "", Request.Method.POST), + "{\"message\":\"Set global routing status for deployments in prod.us-west-1 to 'out'\"}"); + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/status/environment/prod/region/us-west-1", + "", Request.Method.GET), + new File("zone-status-out.json")); + + // DELETE sets zone in + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/inactive/environment/prod/region/us-west-1", + "", Request.Method.DELETE), + "{\"message\":\"Set global routing status for deployments in prod.us-west-1 to 'in'\"}"); + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/status/environment/prod/region/us-west-1", + "", Request.Method.GET), + new File("zone-status-in.json")); + } + + @Test + public void invalid_requests() { + // GET non-existent application + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/status/tenant/t1/application/a1/instance/default/environment/prod/region/us-west-1", + "", Request.Method.GET), + "{\"error-code\":\"BAD_REQUEST\",\"message\":\"No such deployment: t1.a1 in prod.us-west-1\"}", + 400); + + // GET non-existent zone + tester.assertResponse(operatorRequest("http://localhost:8080/routing/v1/status/environment/prod/region/us-north-1", + "", Request.Method.GET), + "{\"error-code\":\"BAD_REQUEST\",\"message\":\"No such zone: prod.us-north-1\"}", + 400); + } + +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/deployment-status-in.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/deployment-status-in.json new file mode 100644 index 00000000000..a364be4b62e --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/deployment-status-in.json @@ -0,0 +1,13 @@ +{ + "deployments": [ + { + "instance": "tenant:application:default", + "cluster": "default", + "environment": "prod", + "region": "us-west-1", + "status": "in", + "agent": "operator", + "changedAt": "(ignore)" + } + ] +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/deployment-status-initial.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/deployment-status-initial.json new file mode 100644 index 00000000000..fba0206fdda --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/deployment-status-initial.json @@ -0,0 +1,13 @@ +{ + "deployments": [ + { + "instance": "tenant:application:default", + "cluster": "default", + "environment": "prod", + "region": "us-west-1", + "status": "in", + "agent": "system", + "changedAt": "(ignore)" + } + ] +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/deployment-status-out.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/deployment-status-out.json new file mode 100644 index 00000000000..5e23e443559 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/deployment-status-out.json @@ -0,0 +1,13 @@ +{ + "deployments": [ + { + "instance": "tenant:application:default", + "cluster": "default", + "environment": "prod", + "region": "us-west-1", + "status": "out", + "agent": "operator", + "changedAt": "(ignore)" + } + ] +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/zone-status-in.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/zone-status-in.json new file mode 100644 index 00000000000..b840589c288 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/zone-status-in.json @@ -0,0 +1,7 @@ +{ + "environment": "prod", + "region": "us-west-1", + "status": "in", + "agent": "operator", + "changedAt": "(ignore)" +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/zone-status-initial.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/zone-status-initial.json new file mode 100644 index 00000000000..cbf9b0c9d0a --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/zone-status-initial.json @@ -0,0 +1,7 @@ +{ + "environment": "prod", + "region": "us-west-1", + "status": "in", + "agent": "system", + "changedAt": "(ignore)" +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/zone-status-out.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/zone-status-out.json new file mode 100644 index 00000000000..86b98e75c6c --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/routing/responses/zone-status-out.json @@ -0,0 +1,7 @@ +{ + "environment": "prod", + "region": "us-west-1", + "status": "out", + "agent": "operator", + "changedAt": "(ignore)" +} |