diff options
author | Martin Polden <mpolden@mpolden.no> | 2017-11-22 14:27:18 +0100 |
---|---|---|
committer | Martin Polden <mpolden@mpolden.no> | 2017-11-22 15:44:13 +0100 |
commit | b52f35351da2e8eba99eedd9a0b2a30ce08c8eff (patch) | |
tree | e377d121f96559f64b715b84bc799af4d50084f7 | |
parent | 8ad3cbb455ab08eb427b8ba439a737a25d2e7592 (diff) |
Reimplement /zone/v2
9 files changed, 325 insertions, 2 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/proxy/ConfigServerRestExecutorImpl.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/proxy/ConfigServerRestExecutorImpl.java index 1a3a0658488..e8b68d0c55a 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/proxy/ConfigServerRestExecutorImpl.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/proxy/ConfigServerRestExecutorImpl.java @@ -14,6 +14,7 @@ import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpDelete; import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPatch; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpPut; import org.apache.http.client.methods.HttpRequestBase; @@ -165,6 +166,12 @@ public class ConfigServerRestExecutorImpl implements ConfigServerRestExecutor { return put; case DELETE: return new HttpDelete(uri); + case PATCH: + HttpPatch patch = new HttpPatch(uri); + if (data != null) { + patch.setEntity(new InputStreamEntity(data)); + } + return patch; default: throw new ProxyException(ErrorResponse.methodNotAllowed("Will not proxy such calls.")); } 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 index 9853320e482..e9db4f9b717 100644 --- 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 @@ -76,8 +76,8 @@ public class Path { 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); + if ( ! pathString.endsWith("/") && rest.length() > 0) + rest.setLength(rest.length() - 1); this.rest = rest.toString(); } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/ZoneApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/ZoneApiHandler.java new file mode 100644 index 00000000000..529b2b25785 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/ZoneApiHandler.java @@ -0,0 +1,116 @@ +// 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.zone.v2; + +import com.yahoo.config.provision.Environment; +import com.yahoo.config.provision.RegionName; +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.slime.Cursor; +import com.yahoo.slime.Slime; +import com.yahoo.vespa.hosted.controller.api.integration.zone.ZoneRegistry; +import com.yahoo.vespa.hosted.controller.proxy.ConfigServerRestExecutor; +import com.yahoo.vespa.hosted.controller.proxy.ProxyException; +import com.yahoo.vespa.hosted.controller.proxy.ProxyRequest; +import com.yahoo.vespa.hosted.controller.restapi.ErrorResponse; +import com.yahoo.vespa.hosted.controller.restapi.Path; +import com.yahoo.vespa.hosted.controller.restapi.SlimeJsonResponse; +import com.yahoo.yolean.Exceptions; + +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.logging.Level; + +/** + * REST API for proxying requests to config servers in a given zone (version 2). + * + * This API does something completely different from /zone/v1, but such is the world. + * + * @author mpolden + */ +@SuppressWarnings("unused") +public class ZoneApiHandler extends LoggingRequestHandler { + + private final ZoneRegistry zoneRegistry; + private final ConfigServerRestExecutor proxy; + + public ZoneApiHandler(Executor executor, AccessLog accessLog, ZoneRegistry zoneRegistry, + ConfigServerRestExecutor proxy) { + super(executor, accessLog); + this.zoneRegistry = zoneRegistry; + this.proxy = proxy; + } + + @Override + public HttpResponse handle(HttpRequest request) { + try { + switch (request.getMethod()) { + case GET: + return get(request); + case POST: + case PUT: + case DELETE: + case PATCH: + return proxy(request); + default: + return ErrorResponse.methodNotAllowed("Method '" + request.getMethod() + "' is unsupported"); + } + } 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 get(HttpRequest request) { + Path path = new Path(request.getUri().getPath()); + if (path.matches("/zone/v2")) { + return root(request); + } + return proxy(request); + } + + private HttpResponse proxy(HttpRequest request) { + Path path = new Path(request.getUri().getPath()); + if (!path.matches("/zone/v2/{environment}/{region}/{*}")) { + return notFound(path); + } + Environment environment = Environment.from(path.get("environment")); + RegionName region = RegionName.from(path.get("region")); + Optional<Zone> zone = zoneRegistry.getZone(environment, region); + if (!zone.isPresent()) { + throw new IllegalArgumentException("No such zone: " + environment.value() + "." + region.value()); + } + try { + return proxy.handle(new ProxyRequest(request, "/zone/v2/")); + } catch (ProxyException | IOException e) { + throw new RuntimeException(e); + } + } + + private HttpResponse root(HttpRequest request) { + Slime slime = new Slime(); + Cursor root = slime.setObject(); + Cursor uris = root.setArray("uris"); + zoneRegistry.zones().forEach(zone -> uris.addString(request.getUri() + .resolve("/zone/v2/") + .resolve(zone.environment().value() + "/") + .resolve(zone.region().value()) + .toString())); + Cursor zones = root.setArray("zones"); + zoneRegistry.zones().forEach(zone -> { + Cursor object = zones.addObject(); + object.setString("environment", zone.environment().value()); + object.setString("region", zone.region().value()); + }); + return new SlimeJsonResponse(slime); + } + + private HttpResponse notFound(Path path) { + return ErrorResponse.notFoundError("Nothing at " + path); + } +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/package-info.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/package-info.java new file mode 100644 index 00000000000..95dfed8b7b2 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/package-info.java @@ -0,0 +1,5 @@ +// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +/** + * @author mpolden + */ +package com.yahoo.vespa.hosted.controller.restapi.zone.v2; diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ConfigServerProxyMock.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ConfigServerProxyMock.java new file mode 100644 index 00000000000..cc915d4d9a1 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/ConfigServerProxyMock.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; + +import com.yahoo.component.AbstractComponent; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.vespa.hosted.controller.proxy.ConfigServerRestExecutor; +import com.yahoo.vespa.hosted.controller.proxy.ProxyException; +import com.yahoo.vespa.hosted.controller.proxy.ProxyRequest; +import com.yahoo.vespa.hosted.controller.restapi.StringResponse; + +import java.io.InputStream; +import java.util.Optional; +import java.util.Scanner; + +/** + * @author mpolden + */ +public class ConfigServerProxyMock extends AbstractComponent implements ConfigServerRestExecutor { + + private volatile ProxyRequest lastReceived = null; + private volatile String requestBody = null; + + @Override + public HttpResponse handle(ProxyRequest proxyRequest) throws ProxyException { + lastReceived = proxyRequest; + // Copy request body as the input stream is drained once the request completes + requestBody = asString(proxyRequest.getData()); + return new StringResponse("ok"); + } + + public Optional<ProxyRequest> lastReceived() { + return Optional.ofNullable(lastReceived); + } + + public Optional<String> lastRequestBody() { + return Optional.ofNullable(requestBody); + } + + private static String asString(InputStream inputStream) { + Scanner scanner = new Scanner(inputStream).useDelimiter("\\A"); + return scanner.hasNext() ? scanner.next() : ""; + } +} 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 19c4def819f..044c5d75d12 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 @@ -48,6 +48,7 @@ public class ControllerContainerTest { " <component id='com.yahoo.vespa.hosted.controller.ConfigServerClientMock'/>" + " <component id='com.yahoo.vespa.hosted.controller.ZoneRegistryMock'/>" + " <component id='com.yahoo.vespa.hosted.controller.Controller'/>" + + " <component id='com.yahoo.vespa.hosted.controller.ConfigServerProxyMock'/>" + " <component id='com.yahoo.vespa.hosted.controller.integration.MockMetricsService'/>" + " <component id='com.yahoo.vespa.hosted.controller.maintenance.ControllerMaintenance'/>" + " <component id='com.yahoo.vespa.hosted.controller.maintenance.JobControl'/>" + @@ -74,6 +75,10 @@ public class ControllerContainerTest { " <binding>http://*/zone/v1</binding>" + " <binding>http://*/zone/v1/*</binding>" + " </handler>" + + " <handler id='com.yahoo.vespa.hosted.controller.restapi.zone.v2.ZoneApiHandler'>" + + " <binding>http://*/zone/v2</binding>" + + " <binding>http://*/zone/v2/*</binding>" + + " </handler>" + "</jdisc>"; protected void assertResponse(Request request, int responseStatus, String responseMessage) throws IOException { diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/ZoneApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/ZoneApiTest.java new file mode 100644 index 00000000000..63899d808f9 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/ZoneApiTest.java @@ -0,0 +1,117 @@ +package com.yahoo.vespa.hosted.controller.restapi.zone.v2; + +import com.yahoo.application.container.handler.Request; +import com.yahoo.application.container.handler.Request.Method; +import com.yahoo.config.provision.Environment; +import com.yahoo.config.provision.RegionName; +import com.yahoo.config.provision.Zone; +import com.yahoo.text.Utf8; +import com.yahoo.vespa.hosted.controller.ConfigServerProxyMock; +import com.yahoo.vespa.hosted.controller.ZoneRegistryMock; +import com.yahoo.vespa.hosted.controller.restapi.ContainerControllerTester; +import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerTest; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +/** + * @author mpolden + */ +public class ZoneApiTest extends ControllerContainerTest { + + private static final String responseFiles = "src/test/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/responses/"; + private static final List<Zone> zones = Arrays.asList( + new Zone(Environment.prod, RegionName.from("us-north-1")), + new Zone(Environment.dev, RegionName.from("us-north-2")), + new Zone(Environment.test, RegionName.from("us-north-3")), + new Zone(Environment.staging, RegionName.from("us-north-4")) + ); + + private ContainerControllerTester tester; + private ConfigServerProxyMock proxy; + + @Before + public void before() { + ZoneRegistryMock zoneRegistry = (ZoneRegistryMock) container.components() + .getComponent(ZoneRegistryMock.class.getName()); + zoneRegistry.setDefaultRegionForEnvironment(Environment.dev, RegionName.from("us-north-2")) + .setZones(zones); + this.tester = new ContainerControllerTester(container, responseFiles); + this.proxy = (ConfigServerProxyMock) container.components().getComponent(ConfigServerProxyMock.class.getName()); + } + + @Test + public void test_requests() throws Exception { + // GET /zone/v2 + tester.containerTester().assertResponse(new Request("http://localhost:8080/zone/v2"), + new File("root.json")); + + // GET /zone/v2/prod/us-north-1 + tester.containerTester().assertResponse(new Request("http://localhost:8080/zone/v2/prod/us-north-1"), + "ok"); + assertEquals("prod", proxy.lastReceived().get().getEnvironment()); + assertEquals("us-north-1", proxy.lastReceived().get().getRegion()); + assertEquals("", proxy.lastReceived().get().getConfigServerRequest()); + assertEquals("GET", proxy.lastReceived().get().getMethod()); + + // GET /zone/v2/nodes/v2/node/?recursive=true + tester.containerTester().assertResponse(new Request("http://localhost:8080/zone/v2/prod/us-north-1/nodes/v2/node/?recursive=true"), + "ok"); + + assertEquals("prod", proxy.lastReceived().get().getEnvironment()); + assertEquals("us-north-1", proxy.lastReceived().get().getRegion()); + assertEquals("/nodes/v2/node/?recursive=true", proxy.lastReceived().get().getConfigServerRequest()); + assertEquals("GET", proxy.lastReceived().get().getMethod()); + + // POST /zone/v2/dev/us-north-2/nodes/v2/command/restart?hostname=node1 + tester.containerTester().assertResponse(new Request("http://localhost:8080/zone/v2/dev/us-north-2/nodes/v2/command/restart?hostname=node1", + new byte[0], Method.POST), + "ok"); + assertEquals("dev", proxy.lastReceived().get().getEnvironment()); + assertEquals("us-north-2", proxy.lastReceived().get().getRegion()); + assertEquals("/nodes/v2/command/restart?hostname=node1", proxy.lastReceived().get().getConfigServerRequest()); + assertEquals("POST", proxy.lastReceived().get().getMethod()); + + // PUT /zone/v2/prod/us-north-1/nodes/v2/state/dirty/node1 + tester.containerTester().assertResponse(new Request("http://localhost:8080/zone/v2/prod/us-north-1/nodes/v2/state/dirty/node1", + new byte[0], Method.PUT), "ok"); + assertEquals("prod", proxy.lastReceived().get().getEnvironment()); + assertEquals("us-north-1", proxy.lastReceived().get().getRegion()); + assertEquals("/nodes/v2/state/dirty/node1", proxy.lastReceived().get().getConfigServerRequest()); + assertEquals("PUT", proxy.lastReceived().get().getMethod()); + + // DELETE /zone/v2/prod/us-north-1/nodes/v2/node/node1 + tester.containerTester().assertResponse(new Request("http://localhost:8080/zone/v2/prod/us-north-1/nodes/v2/node/node1", + new byte[0], Method.DELETE), "ok"); + assertEquals("prod", proxy.lastReceived().get().getEnvironment()); + assertEquals("us-north-1", proxy.lastReceived().get().getRegion()); + assertEquals("/nodes/v2/node/node1", proxy.lastReceived().get().getConfigServerRequest()); + assertEquals("DELETE", proxy.lastReceived().get().getMethod()); + + // PATCH /zone/v2/prod/us-north-1/nodes/v2/node/node1 + tester.containerTester().assertResponse(new Request("http://localhost:8080/zone/v2/prod/us-north-1/nodes/v2/node/node1", + Utf8.toBytes("{\"currentRestartGeneration\": 1}"), + Method.PATCH), "ok"); + assertEquals("prod", proxy.lastReceived().get().getEnvironment()); + assertEquals("us-north-1", proxy.lastReceived().get().getRegion()); + assertEquals("/nodes/v2/node/node1", proxy.lastReceived().get().getConfigServerRequest()); + assertEquals("PATCH", proxy.lastReceived().get().getMethod()); + assertEquals("{\"currentRestartGeneration\": 1}", proxy.lastRequestBody().get()); + } + + @Test + public void test_invalid_requests() throws Exception { + // GET /zone/v2/prod/us-north-34/nodes/v2 + tester.containerTester().assertResponse(new Request("http://localhost:8080/zone/v2/prod/us-north-42/nodes/v2", + new byte[0], Method.POST), + new File("unknown-zone.json"), 400); + assertFalse(proxy.lastReceived().isPresent()); + } + +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/responses/root.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/responses/root.json new file mode 100644 index 00000000000..ab168854267 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/responses/root.json @@ -0,0 +1,26 @@ +{ + "uris": [ + "http://localhost:8080/zone/v2/prod/us-north-1", + "http://localhost:8080/zone/v2/dev/us-north-2", + "http://localhost:8080/zone/v2/test/us-north-3", + "http://localhost:8080/zone/v2/staging/us-north-4" + ], + "zones": [ + { + "environment": "prod", + "region": "us-north-1" + }, + { + "environment": "dev", + "region": "us-north-2" + }, + { + "environment": "test", + "region": "us-north-3" + }, + { + "environment": "staging", + "region": "us-north-4" + } + ] +} diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/responses/unknown-zone.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/responses/unknown-zone.json new file mode 100644 index 00000000000..c7d6e4b8400 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/zone/v2/responses/unknown-zone.json @@ -0,0 +1,4 @@ +{ + "error-code": "BAD_REQUEST", + "message": "No such zone: prod.us-north-42" +} |