summaryrefslogtreecommitdiffstats
path: root/controller-server
diff options
context:
space:
mode:
authorOla Aunrønning <ola.aunroe@gmail.com>2023-10-20 18:13:27 +0200
committerGitHub <noreply@github.com>2023-10-20 18:13:27 +0200
commit16b0ac7203d6433e631de558e30622b297879acb (patch)
treeb1ecbe268a94310302c88b9520d88e08d027622c /controller-server
parent46bd10fe1fbbbbf388155bf72e73ab26fbd0bfab (diff)
Revert "Move PricingApiHandler and related code to internal repo"
Diffstat (limited to 'controller-server')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandler.java218
-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.java185
3 files changed, 407 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..8ca2936eee7
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandler.java
@@ -0,0 +1,218 @@
+// Copyright Vespa.ai. 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.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.ApplicationResources;
+import com.yahoo.vespa.hosted.controller.api.integration.pricing.PriceInformation;
+import com.yahoo.vespa.hosted.controller.api.integration.pricing.Prices;
+import com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingController;
+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.math.RoundingMode;
+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 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.math.BigDecimal.ZERO;
+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 final Controller controller;
+ private final PricingController pricingController;
+
+ @Inject
+ public PricingApiHandler(Context parentCtx, Controller controller, PricingController pricingController) {
+ super(parentCtx);
+ this.controller = controller;
+ this.pricingController = pricingController;
+ }
+
+ @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();
+ var priceParameters = parseQuery(rawQuery);
+ Prices price = calculatePrice(priceParameters);
+ return response(price, priceParameters);
+ }
+
+ private Prices calculatePrice(PriceParameters priceParameters) {
+ return pricingController.priceForApplications(priceParameters.appResources, priceParameters.pricingInfo, priceParameters.plan);
+ }
+
+ private PriceParameters parseQuery(String rawQuery) {
+ if (rawQuery == null) throw new IllegalArgumentException("No price information found in query");
+ List<String> elements = Arrays.stream(URLDecoder.decode(rawQuery, UTF_8).split("&")).toList();
+ return parseQuery(elements);
+ }
+
+ private PriceParameters parseQuery(List<String> elements) {
+ var supportLevel = SupportLevel.BASIC;
+ var enclave = false;
+ var committedSpend = ZERO;
+ var applicationName = "default";
+ var plan = controller.serviceRegistry().planRegistry().defaultPlan(); // fallback to default plan if not supplied
+ List<ApplicationResources> appResources = new ArrayList<>();
+
+ for (Pair<String, String> entry : keysAndValues(elements)) {
+ var value = entry.getSecond();
+ switch (entry.getFirst().toLowerCase()) {
+ case "committedspend" -> committedSpend = new BigDecimal(value);
+ case "planid" -> plan = plan(value).orElseThrow(() -> new IllegalArgumentException("Unknown plan id " + value));
+ case "supportlevel" -> supportLevel = SupportLevel.valueOf(value.toUpperCase());
+ case "application" -> appResources.add(applicationResources(value));
+ default -> throw new IllegalArgumentException("Unknown query parameter '" + entry.getFirst() + '\'');
+ }
+ }
+
+ PricingInfo pricingInfo = new PricingInfo(supportLevel, committedSpend);
+ return new PriceParameters(List.of(), pricingInfo, plan, appResources);
+ }
+
+ private ApplicationResources applicationResources(String appResourcesString) {
+ List<String> elements = List.of(appResourcesString.split(","));
+
+ var vcpu = ZERO;
+ var memoryGb = ZERO;
+ var diskGb = ZERO;
+ var gpuMemoryGb = ZERO;
+ var enclaveVcpu = ZERO;
+ var enclaveMemoryGb = ZERO;
+ var enclaveDiskGb = ZERO;
+ var enclaveGpuMemoryGb = ZERO;
+
+ for (var element : keysAndValues(elements)) {
+ var value = element.getSecond();
+ switch (element.getFirst().toLowerCase()) {
+ case "vcpu" -> vcpu = new BigDecimal(value);
+ case "memorygb" -> memoryGb = new BigDecimal(value);
+ case "diskgb" -> diskGb = new BigDecimal(value);
+ case "gpumemorygb" -> gpuMemoryGb = new BigDecimal(value);
+
+ case "enclavevcpu" -> enclaveVcpu = new BigDecimal(value);
+ case "enclavememorygb" -> enclaveMemoryGb = new BigDecimal(value);
+ case "enclavediskgb" -> enclaveDiskGb = new BigDecimal(value);
+ case "enclavegpumemorygb" -> enclaveGpuMemoryGb = new BigDecimal(value);
+
+ default -> throw new IllegalArgumentException("Unknown key '" + element.getFirst() + '\'');
+ }
+ }
+
+ return new ApplicationResources(vcpu, memoryGb, diskGb, gpuMemoryGb,
+ enclaveVcpu, enclaveMemoryGb, enclaveDiskGb, enclaveGpuMemoryGb);
+ }
+
+ private List<Pair<String, String>> keysAndValues(List<String> elements) {
+ return elements.stream().map(element -> {
+ var index = element.indexOf("=");
+ if (index <= 0 || index == element.length() - 1)
+ throw new IllegalArgumentException("Error in query parameter, expected '=' between key and value: '" + element + '\'');
+ return new Pair<>(element.substring(0, index), element.substring(index + 1));
+ })
+ .toList();
+ }
+
+ private Optional<Plan> plan(String element) {
+ return controller.serviceRegistry().planRegistry().plan(element);
+ }
+
+ private static SlimeJsonResponse response(Prices prices, PriceParameters priceParameters) {
+ var slime = new Slime();
+ Cursor cursor = slime.setObject();
+
+ var applicationsArray = cursor.setArray("applications");
+ applicationPrices(applicationsArray, prices.priceInformationApplications(), priceParameters);
+
+ var priceInfoArray = cursor.setArray("priceInfo");
+ addItem(priceInfoArray, "Enclave (minimum $10k per month)", prices.totalPriceInformation().enclaveDiscount());
+ addItem(priceInfoArray, "Committed spend", prices.totalPriceInformation().committedAmountDiscount());
+
+ setBigDecimal(cursor, "totalAmount", prices.totalPriceInformation().totalAmount());
+
+ return new SlimeJsonResponse(slime);
+ }
+
+ private static void applicationPrices(Cursor applicationPricesArray, List<PriceInformation> applicationPrices, PriceParameters priceParameters) {
+ applicationPrices.forEach(priceInformation -> {
+ var element = applicationPricesArray.addObject();
+ var array = element.setArray("priceInfo");
+ addItem(array, supportLevelDescription(priceParameters), priceInformation.listPriceWithSupport());
+ addItem(array, "Enclave", priceInformation.enclaveDiscount());
+ addItem(array, "Volume discount", priceInformation.volumeDiscount());
+ });
+ }
+
+ private static String supportLevelDescription(PriceParameters priceParameters) {
+ String supportLevel = priceParameters.pricingInfo.supportLevel().name();
+ return supportLevel.substring(0,1).toUpperCase() + supportLevel.substring(1).toLowerCase() + " support unit price";
+ }
+
+ private static void addItem(Cursor array, String name, BigDecimal amount) {
+ if (amount.compareTo(BigDecimal.ZERO) != 0) {
+ var o = array.addObject();
+ o.setString("description", name);
+ setBigDecimal(o, "amount", amount);
+ }
+ }
+
+ private static void setBigDecimal(Cursor cursor, String name, BigDecimal value) {
+ cursor.setString(name, value.setScale(2, RoundingMode.HALF_UP).toPlainString());
+ }
+
+ private record PriceParameters(List<ClusterResources> clusterResources, PricingInfo pricingInfo, Plan plan,
+ List<ApplicationResources> appResources) {
+
+ }
+
+}
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..f2ce0dfeef2
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/pricing/PricingApiHandlerTest.java
@@ -0,0 +1,185 @@
+// Copyright Vespa.ai. 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.api.integration.pricing.PricingInfo;
+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 com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingInfo.SupportLevel.BASIC;
+import static com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingInfo.SupportLevel.COMMERCIAL;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * @author hmusum
+ */
+public class PricingApiHandlerTest extends ControllerContainerCloudTest {
+
+ @Test
+ void testPricingInfoBasic() {
+ tester().assertJsonResponse(request("/pricing/v1/pricing?supportLevel=basic&committedSpend=0"),
+ """
+ { "applications": [ ], "priceInfo": [ ], "totalAmount": "0.00" }
+ """,
+ 200);
+
+ var request = request("/pricing/v1/pricing?" + urlEncodedPriceInformation1App(BASIC));
+ tester().assertJsonResponse(request, """
+ {
+ "applications": [
+ {
+ "priceInfo": [
+ {"description": "Basic support unit price", "amount": "4.30"},
+ {"description": "Volume discount", "amount": "-0.10"}
+ ]
+ }
+ ],
+ "priceInfo": [
+ {"description": "Committed spend", "amount": "-0.20"}
+ ],
+ "totalAmount": "4.00"
+ }
+ """,
+ 200);
+ }
+
+ @Test
+ void testPricingInfoBasicEnclave() {
+ var request = request("/pricing/v1/pricing?" + urlEncodedPriceInformation1AppEnclave(BASIC));
+ tester().assertJsonResponse(request, """
+ {
+ "applications": [
+ {
+ "priceInfo": [
+ {"description": "Basic support unit price", "amount": "4.30"},
+ {"description": "Enclave", "amount": "-0.15"},
+ {"description": "Volume discount", "amount": "-0.10"}
+ ]
+ }
+ ],
+ "priceInfo": [
+ {"description": "Enclave (minimum $10k per month)", "amount": "10.15"},
+ {"description": "Committed spend", "amount": "-0.20"}
+ ],
+ "totalAmount": "3.85"
+ }
+ """,
+ 200);
+ }
+
+ @Test
+ void testPricingInfoCommercialEnclave() {
+ var request = request("/pricing/v1/pricing?" + urlEncodedPriceInformation1AppEnclave(COMMERCIAL));
+ tester().assertJsonResponse(request, """
+ {
+ "applications": [
+ {
+ "priceInfo": [
+ {"description": "Commercial support unit price", "amount": "13.30"},
+ {"description": "Enclave", "amount": "-0.15"},
+ {"description": "Volume discount", "amount": "-0.10"}
+ ]
+ }
+ ],
+ "priceInfo": [
+ {"description": "Enclave (minimum $10k per month)", "amount": "1.15"},
+ {"description": "Committed spend", "amount": "-0.20"}
+ ],
+ "totalAmount": "12.85"
+ }
+ """,
+ 200);
+ }
+
+ @Test
+ void testPricingInfoCommercialEnclave2Apps() {
+ var request = request("/pricing/v1/pricing?" + urlEncodedPriceInformation2AppsEnclave(COMMERCIAL));
+ tester().assertJsonResponse(request, """
+ {
+ "applications": [
+ {
+ "priceInfo": [
+ {"description": "Commercial support unit price", "amount": "13.30"},
+ {"description": "Enclave", "amount": "-0.15"},
+ {"description": "Volume discount", "amount": "-0.10"}
+ ]
+ },
+ {
+ "priceInfo": [
+ {"description": "Commercial support unit price", "amount": "13.30"},
+ {"description": "Enclave", "amount": "-0.15"},
+ {"description": "Volume discount", "amount": "-0.10"}
+ ]
+ }
+ ],
+ "priceInfo": [ ],
+ "totalAmount": "26.10"
+ }
+ """,
+ 200);
+ }
+
+ @Test
+ void testInvalidRequests() {
+ ContainerTester tester = tester();
+ tester.assertJsonResponse(request("/pricing/v1/pricing"),
+ "{\"error-code\":\"BAD_REQUEST\",\"message\":\"No price information found in query\"}",
+ 400);
+ tester.assertJsonResponse(request("/pricing/v1/pricing?"),
+ "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Error in query parameter, expected '=' between key and value: ''\"}",
+ 400);
+ tester.assertJsonResponse(request("/pricing/v1/pricing?supportLevel=basic&committedSpend=0&resources"),
+ "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Error in query parameter, expected '=' between key and value: 'resources'\"}",
+ 400);
+ tester.assertJsonResponse(request("/pricing/v1/pricing?supportLevel=basic&committedSpend=0&resources="),
+ "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Error in query parameter, expected '=' between key and value: 'resources='\"}",
+ 400);
+ tester.assertJsonResponse(request("/pricing/v1/pricing?supportLevel=basic&committedSpend=0&key=value"),
+ "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Unknown query parameter 'key'\"}",
+ 400);
+ tester.assertJsonResponse(request("/pricing/v1/pricing?supportLevel=basic&committedSpend=0&application=key%3Dvalue"),
+ "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Unknown key 'key'\"}",
+ 400);
+ }
+
+ private ContainerTester tester() {
+ ContainerTester tester = new ContainerTester(container, null);
+ assertEquals(SystemName.Public, tester.controller().system());
+ return tester;
+ }
+
+ /**
+ * 1 app, with 2 clusters (with total resources for all clusters with each having
+ * 1 node, with 4 vcpu, 8 Gb memory, 100 Gb disk and no GPU,
+ * price will be 20000 + 2000 + 200
+ */
+ String urlEncodedPriceInformation1App(PricingInfo.SupportLevel supportLevel) {
+ return "application=" + URLEncoder.encode("vcpu=4,memoryGb=8,diskGb=100,gpuMemoryGb=0", UTF_8) +
+ "&supportLevel=" + supportLevel.name().toLowerCase() + "&committedSpend=20";
+ }
+
+ /**
+ * 1 app, with 2 clusters (with total resources for all clusters with each having
+ * 1 node, with 4 vcpu, 8 Gb memory, 100 Gb disk and no GPU,
+ * price will be 20000 + 2000 + 200
+ */
+ String urlEncodedPriceInformation1AppEnclave(PricingInfo.SupportLevel supportLevel) {
+ return "application=" + URLEncoder.encode("enclaveVcpu=4,enclaveMemoryGb=8,enclaveDiskGb=100,enclaveGpuMemoryGb=0", UTF_8) +
+ "&supportLevel=" + supportLevel.name().toLowerCase() + "&committedSpend=20";
+ }
+
+ /**
+ * 2 apps, with 1 cluster (with total resources for all clusters with each having
+ * 1 node, with 4 vcpu, 8 Gb memory, 100 Gb disk and no GPU,
+ */
+ String urlEncodedPriceInformation2AppsEnclave(PricingInfo.SupportLevel supportLevel) {
+ return "application=" + URLEncoder.encode("enclaveVcpu=4,enclaveMemoryGb=8,enclaveDiskGb=100,enclaveGpuMemoryGb=0", UTF_8) +
+ "&application=" + URLEncoder.encode("enclaveVcpu=4,enclaveMemoryGb=8,enclaveDiskGb=100,enclaveGpuMemoryGb=0", UTF_8) +
+ "&supportLevel=" + supportLevel.name().toLowerCase() + "&committedSpend=0";
+ }
+
+}