summaryrefslogtreecommitdiffstats
path: root/controller-server
diff options
context:
space:
mode:
authorHarald Musum <musum@verizonmedia.com>2023-10-10 14:43:07 +0200
committerGitHub <noreply@github.com>2023-10-10 14:43:07 +0200
commit050149b3cc0702299159540144635767379a6e0d (patch)
treef001c716ccbadc8a7ff35d22bc8ea4e346db92a8 /controller-server
parentb5e9551aef5230904d726b47578b4cb6b1fcfe2d (diff)
parentd8b9d80eb27523f9b4374f90a49051ed26818285 (diff)
Merge pull request #28832 from vespa-engine/hmusum/add-handler-for-pricing-api
Add handler for pricing API
Diffstat (limited to 'controller-server')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandler.java176
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/ControllerContainerTest.java4
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandlerTest.java65
3 files changed, 245 insertions, 0 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandler.java
new file mode 100644
index 00000000000..b6b3c8584fd
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandler.java
@@ -0,0 +1,176 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.pricing;
+
+import com.yahoo.collections.Pair;
+import com.yahoo.component.annotation.Inject;
+import com.yahoo.config.provision.ClusterResources;
+import com.yahoo.config.provision.NodeResources;
+import com.yahoo.container.jdisc.HttpRequest;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.ThreadedHttpRequestHandler;
+import com.yahoo.restapi.ErrorResponse;
+import com.yahoo.restapi.Path;
+import com.yahoo.restapi.SlimeJsonResponse;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Slime;
+import com.yahoo.text.Text;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.Plan;
+import com.yahoo.vespa.hosted.controller.api.integration.pricing.PriceInformation;
+import com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingInfo;
+import com.yahoo.vespa.hosted.controller.restapi.ErrorResponses;
+import com.yahoo.yolean.Exceptions;
+
+import java.math.BigDecimal;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+import static com.yahoo.jdisc.http.HttpRequest.Method.GET;
+import static com.yahoo.restapi.ErrorResponse.methodNotAllowed;
+import static com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingInfo.SupportLevel;
+import static java.lang.Double.parseDouble;
+import static java.lang.Integer.parseInt;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * API for calculating price information
+ *
+ * @author hmusum
+ */
+@SuppressWarnings("unused") // Handler
+public class PricingApiHandler extends ThreadedHttpRequestHandler {
+
+ private static final Logger log = Logger.getLogger(PricingApiHandler.class.getName());
+ private static final BigDecimal SCALED_ZERO = BigDecimal.ZERO.setScale(2);
+
+ private final Controller controller;
+
+ @Inject
+ public PricingApiHandler(Context parentCtx, Controller controller) {
+ super(parentCtx);
+ this.controller = controller;
+ }
+
+ @Override
+ public HttpResponse handle(HttpRequest request) {
+ if (request.getMethod() != GET)
+ return methodNotAllowed("Method '" + request.getMethod() + "' is not supported");
+
+ try {
+ return handleGET(request);
+ } catch (IllegalArgumentException e) {
+ return ErrorResponse.badRequest(Exceptions.toMessageString(e));
+ } catch (RuntimeException e) {
+ return ErrorResponses.logThrowing(request, log, e);
+ }
+ }
+
+ private HttpResponse handleGET(HttpRequest request) {
+ Path path = new Path(request.getUri());
+ if (path.matches("/pricing/v1/pricing")) return pricing(request);
+
+ return ErrorResponse.notFoundError(Text.format("No '%s' handler at '%s'", request.getMethod(),
+ request.getUri().getPath()));
+ }
+
+ private HttpResponse pricing(HttpRequest request) {
+ String rawQuery = request.getUri().getRawQuery();
+ PriceInformation price = parseQuery(rawQuery);
+ return response(price);
+ }
+
+ private PriceInformation parseQuery(String rawQuery) {
+ String[] elements = URLDecoder.decode(rawQuery, UTF_8).split("&");
+ if (elements.length == 0) throw new IllegalArgumentException("no price information found in query");
+
+ var supportLevel = SupportLevel.BASIC;
+ var enclave = false;
+ var committedSpend = 0d;
+ var plan = controller.serviceRegistry().planRegistry().defaultPlan(); // fallback to default plan if not supplied
+ List<ClusterResources> clusterResources = new ArrayList<>();
+
+ for (Pair<String, String> entry : keysAndValues(elements)) {
+ switch (entry.getFirst()) {
+ case "committedSpend" -> committedSpend = parseDouble(entry.getSecond());
+ case "enclave" -> enclave = Boolean.parseBoolean(entry.getSecond());
+ case "planId" -> plan = plan(entry.getSecond())
+ .orElseThrow(() -> new IllegalArgumentException("Unknown plan id " + entry.getSecond()));
+ case "supportLevel" -> supportLevel = SupportLevel.valueOf(entry.getSecond().toUpperCase());
+ case "resources" -> clusterResources.add(clusterResources(entry.getSecond()));
+ }
+ }
+ if (clusterResources.size() < 1) throw new IllegalArgumentException("No cluster resources found in query");
+
+ PricingInfo pricingInfo = new PricingInfo(enclave, supportLevel, committedSpend);
+ return controller.serviceRegistry().pricingController().price(clusterResources, pricingInfo, plan);
+ }
+
+ private ClusterResources clusterResources(String resourcesString) {
+ String[] elements = resourcesString.split(",");
+ if (elements.length == 0)
+ throw new IllegalArgumentException("nothing found in cluster resources: " + resourcesString);
+
+ var nodes = 0;
+ var vcpu = 0d;
+ var memoryGb = 0d;
+ var diskGb = 0d;
+ var gpuMemoryGb = 0d;
+
+ for (var element : keysAndValues(elements)) {
+ switch (element.getFirst()) {
+ case "nodes" -> nodes = parseInt(element.getSecond());
+ case "vcpu" -> vcpu = parseDouble(element.getSecond());
+ case "memoryGb" -> memoryGb = parseDouble(element.getSecond());
+ case "diskGb" -> diskGb = parseDouble(element.getSecond());
+ case "gpuMemoryGb" -> gpuMemoryGb = parseDouble(element.getSecond());
+ }
+ }
+
+ var nodeResources = new NodeResources(vcpu, memoryGb, diskGb, 0); // 0 bandwidth, not used in price calculation
+ if (gpuMemoryGb > 0)
+ nodeResources = nodeResources.with(new NodeResources.GpuResources(1, gpuMemoryGb));
+ return new ClusterResources(nodes, 1, nodeResources);
+ }
+
+ private List<Pair<String, String>> keysAndValues(String[] elements) {
+ return Arrays.stream(elements).map(element -> {
+ var index = element.indexOf("=");
+ if (index <= 0 ) throw new IllegalArgumentException("Error in query parameter, expected '=' between key and value: " + element);
+ return new Pair<>(element.substring(0, index), element.substring(index + 1));
+ })
+ .collect(Collectors.toList());
+ }
+
+ private Optional<Plan> plan(String element) {
+ return controller.serviceRegistry().planRegistry().plan(element);
+ }
+
+ private static SlimeJsonResponse response(PriceInformation priceInfo) {
+ var slime = new Slime();
+ Cursor cursor = slime.setObject();
+
+ var array = cursor.setArray("priceInfo");
+ addItem(array, "List price", priceInfo.listPrice());
+ addItem(array, "Enclave discount", priceInfo.enclaveDiscount());
+ addItem(array, "Volume discount", priceInfo.volumeDiscount());
+ addItem(array, "Committed spend", priceInfo.committedAmountDiscount());
+
+ cursor.setString("totalAmount", priceInfo.totalAmount().toPlainString());
+
+ return new SlimeJsonResponse(slime);
+ }
+
+ private static void addItem(Cursor array, String name, BigDecimal amount) {
+ if (amount.compareTo(BigDecimal.ZERO) != 0) {
+ var o = array.addObject();
+ o.setString("description", name);
+ o.setString("amount", SCALED_ZERO.add(amount).toPlainString());
+ }
+ }
+
+}
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 3ada598f4f8..6103b715744 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
@@ -77,6 +77,7 @@ public class ControllerContainerTest {
<component id='com.yahoo.vespa.hosted.controller.Controller'/>
<component id='com.yahoo.vespa.hosted.controller.integration.ConfigServerProxyMock'/>
<component id='com.yahoo.vespa.hosted.controller.maintenance.ControllerMaintenance'/>
+ <component id='com.yahoo.vespa.hosted.controller.api.integration.MockPricingController'/>
<component id='com.yahoo.vespa.hosted.controller.api.integration.stubs.MockMavenRepository'/>
<component id='com.yahoo.vespa.hosted.controller.api.integration.stubs.MockUserManagement'/>
<component id='com.yahoo.vespa.hosted.controller.integration.SecretStoreMock'/>
@@ -117,6 +118,9 @@ public class ControllerContainerTest {
<handler id='com.yahoo.vespa.hosted.controller.restapi.changemanagement.ChangeManagementApiHandler'>
<binding>http://localhost/changemanagement/v1/*</binding>
</handler>
+ <handler id='com.yahoo.vespa.hosted.controller.restapi.pricing.PricingApiHandler'>
+ <binding>http://localhost/pricing/v1/*</binding>
+ </handler>
%s
</container>
""".formatted(system().value(), variablePartXml());
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandlerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandlerTest.java
new file mode 100644
index 00000000000..09ff6bbc4b1
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandlerTest.java
@@ -0,0 +1,65 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.restapi.pricing;
+
+import com.yahoo.config.provision.SystemName;
+import com.yahoo.vespa.hosted.controller.restapi.ContainerTester;
+import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerCloudTest;
+import org.junit.jupiter.api.Test;
+
+import java.net.URLEncoder;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * @author hmusum
+ */
+public class PricingApiHandlerTest extends ControllerContainerCloudTest {
+
+ private static final String responseFiles = "src/test/java/com/yahoo/vespa/hosted/controller/restapi/pricing/responses/";
+
+ @Test
+ void testPricingInfo() {
+ ContainerTester tester = new ContainerTester(container, responseFiles);
+ assertEquals(SystemName.Public, tester.controller().system());
+
+ var request = request("/pricing/v1/pricing?" + urlEncodedPriceInformation());
+ tester.assertJsonResponse(request, """
+ {
+ "priceInfo": [
+ {"description": "List price", "amount": "2400.00"},
+ {"description": "Volume discount", "amount": "-5.00"}
+ ],
+ "totalAmount": "2395.00"
+ }
+ """,
+ 200);
+ }
+
+ @Test
+ void testPricingInfoWithIncompleteParameter() {
+ ContainerTester tester = new ContainerTester(container, responseFiles);
+ assertEquals(SystemName.Public, tester.controller().system());
+
+ var request = request("/pricing/v1/pricing?" + urlEncodedPriceInformationWithMissingValueInResourcs());
+ tester.assertJsonResponse(request,
+ "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Error in query parameter, expected '=' between key and value: resources\"}",
+ 400);
+ }
+
+ /**
+ * 2 clusters, with each having 1 node, with 1 vcpu, 1 Gb memory, 10 Gb disk and no GPU
+ * price will be 20000 + 2000 + 200
+ */
+ String urlEncodedPriceInformation() {
+ String resources = URLEncoder.encode("nodes=1,vcpu=1,memoryGb=1,diskGb=10,gpuMemoryGb=0", UTF_8);
+ return "supportLevel=basic&committedSpend=0&enclave=false" +
+ "&resources=" + resources +
+ "&resources=" + resources;
+ }
+
+ String urlEncodedPriceInformationWithMissingValueInResourcs() {
+ return URLEncoder.encode("supportLevel=basic&committedSpend=0&enclave=false&resources", UTF_8);
+ }
+
+}