summaryrefslogtreecommitdiffstats
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
parent46bd10fe1fbbbbf388155bf72e73ab26fbd0bfab (diff)
Revert "Move PricingApiHandler and related code to internal repo"
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java64
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/ApplicationResources.java33
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PriceInformation.java29
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/Prices.java8
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingController.java24
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingInfo.java14
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/package-info.java5
-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
10 files changed, 584 insertions, 0 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java
new file mode 100644
index 00000000000..b451df87727
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java
@@ -0,0 +1,64 @@
+// 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.api.integration;
+
+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 java.math.BigDecimal;
+import java.util.List;
+
+import static com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingInfo.SupportLevel.BASIC;
+import static java.math.BigDecimal.ZERO;
+
+public class MockPricingController implements PricingController {
+
+ private static final BigDecimal cpuCost = new BigDecimal("1.00");
+ private static final BigDecimal memoryCost = new BigDecimal("0.10");
+ private static final BigDecimal diskCost = new BigDecimal("0.005");
+
+ @Override
+ public Prices priceForApplications(List<ApplicationResources> applicationResources, PricingInfo pricingInfo, Plan plan) {
+ List<PriceInformation> appPrices = applicationResources.stream()
+ .map(resources -> {
+ BigDecimal listPrice = resources.vcpu().multiply(cpuCost)
+ .add(resources.memoryGb().multiply(memoryCost))
+ .add(resources.diskGb().multiply(diskCost))
+ .add(resources.enclaveVcpu().multiply(cpuCost))
+ .add(resources.enclaveMemoryGb().multiply(memoryCost))
+ .add(resources.enclaveDiskGb().multiply(diskCost));
+
+ BigDecimal supportLevelCost = pricingInfo.supportLevel() == BASIC ? new BigDecimal("-1.00") : new BigDecimal("8.00");
+ BigDecimal listPriceWithSupport = listPrice.add(supportLevelCost);
+ BigDecimal enclaveDiscount = isEnclave(resources) ? new BigDecimal("-0.15") : BigDecimal.ZERO;
+ BigDecimal volumeDiscount = new BigDecimal("-0.1");
+ BigDecimal appTotalAmount = listPrice.add(supportLevelCost).add(enclaveDiscount).add(volumeDiscount);
+
+ return new PriceInformation(listPriceWithSupport,
+ volumeDiscount,
+ ZERO,
+ enclaveDiscount,
+ appTotalAmount);
+ })
+ .toList();
+
+ PriceInformation sum = PriceInformation.sum(appPrices);
+ System.out.println(pricingInfo.committedHourlyAmount());
+ var committedAmountDiscount = pricingInfo.committedHourlyAmount().compareTo(ZERO) > 0 ? new BigDecimal("-0.2") : ZERO;
+ var totalAmount = sum.totalAmount().add(committedAmountDiscount);
+ var enclave = ZERO;
+ if (applicationResources.stream().anyMatch(ApplicationResources::enclave) && totalAmount.compareTo(new BigDecimal("14.00")) < 0)
+ enclave = new BigDecimal("14.00").subtract(totalAmount);
+ var totalPrice = new PriceInformation(ZERO, ZERO, committedAmountDiscount, enclave, totalAmount);
+
+ return new Prices(appPrices, totalPrice);
+ }
+
+ private static boolean isEnclave(ApplicationResources resources) {
+ return resources.enclaveVcpu().compareTo(ZERO) > 0;
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/ApplicationResources.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/ApplicationResources.java
new file mode 100644
index 00000000000..106d9ab6bbe
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/ApplicationResources.java
@@ -0,0 +1,33 @@
+package com.yahoo.vespa.hosted.controller.api.integration.pricing;
+
+import java.math.BigDecimal;
+
+import static java.math.BigDecimal.ZERO;
+
+/**
+ * @param vcpu vcpus summed over all clusters, instances, zones
+ * @param memoryGb memory in Gb summed over all clusters, instances, zones
+ * @param diskGb disk in Gb summed over all clusters, instances, zones
+ * @param gpuMemoryGb GPU memory in Gb summed over all clusters, instances, zones
+ * @param enclaveVcpu vcpus summed over all clusters, instances, zones
+ * @param enclaveMemoryGb memory in Gb summed over all clusters, instances, zones
+ * @param enclaveDiskGb disk in Gb summed over all clusters, instances, zones
+ * @param enclaveGpuMemoryGb GPU memory in Gb summed over all clusters, instances, zones
+ */
+public record ApplicationResources(BigDecimal vcpu, BigDecimal memoryGb, BigDecimal diskGb,
+ BigDecimal gpuMemoryGb, BigDecimal enclaveVcpu, BigDecimal enclaveMemoryGb,
+ BigDecimal enclaveDiskGb, BigDecimal enclaveGpuMemoryGb) {
+
+ public static ApplicationResources create(BigDecimal vcpu, BigDecimal memoryGb,
+ BigDecimal diskGb, BigDecimal gpuMemoryGb) {
+ return new ApplicationResources(vcpu, memoryGb, diskGb, gpuMemoryGb, ZERO, ZERO, ZERO, ZERO);
+ }
+
+ public static ApplicationResources createEnclave(BigDecimal vcpu, BigDecimal memoryGb,
+ BigDecimal diskGb, BigDecimal gpuMemoryGb) {
+ return new ApplicationResources(ZERO, ZERO, ZERO, ZERO, vcpu, memoryGb, diskGb, gpuMemoryGb);
+ }
+
+ public boolean enclave() { return enclaveVcpu().compareTo(ZERO) > 0; }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PriceInformation.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PriceInformation.java
new file mode 100644
index 00000000000..50463553f8e
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PriceInformation.java
@@ -0,0 +1,29 @@
+// 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.api.integration.pricing;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static java.math.BigDecimal.ZERO;
+
+public record PriceInformation(BigDecimal listPriceWithSupport, BigDecimal volumeDiscount,
+ BigDecimal committedAmountDiscount, BigDecimal enclaveDiscount, BigDecimal totalAmount) {
+
+ public static PriceInformation empty() { return new PriceInformation(ZERO, ZERO, ZERO, ZERO, ZERO); }
+
+ public static PriceInformation sum(List<PriceInformation> priceInformationList) {
+ var result = PriceInformation.empty();
+ for (var prices : priceInformationList)
+ result = result.add(prices);
+ return result;
+ }
+
+ public PriceInformation add(PriceInformation priceInformation) {
+ return new PriceInformation(this.listPriceWithSupport().add(priceInformation.listPriceWithSupport()),
+ this.volumeDiscount().add(priceInformation.volumeDiscount()),
+ this.committedAmountDiscount().add(priceInformation.committedAmountDiscount()),
+ this.enclaveDiscount().add(priceInformation.enclaveDiscount()),
+ this.totalAmount().add(priceInformation.totalAmount()));
+ }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/Prices.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/Prices.java
new file mode 100644
index 00000000000..650a07c51e0
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/Prices.java
@@ -0,0 +1,8 @@
+// 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.api.integration.pricing;
+
+import java.util.List;
+
+public record Prices(List<PriceInformation> priceInformationApplications, PriceInformation totalPriceInformation) {
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingController.java
new file mode 100644
index 00000000000..7b082932588
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingController.java
@@ -0,0 +1,24 @@
+// 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.api.integration.pricing;
+
+import com.yahoo.vespa.hosted.controller.api.integration.billing.Plan;
+
+import java.util.List;
+
+/**
+ * A service that calculates price information based on cluster resources, plan, service level etc.
+ *
+ * @author hmusum
+ */
+public interface PricingController {
+
+ /**
+ *
+ * @param applicationResources resources used by an application
+ * @param pricingInfo pricing info
+ * @param plan the plan to use for this calculation
+ * @return a PriceInformation instance
+ */
+ Prices priceForApplications(List<ApplicationResources> applicationResources, PricingInfo pricingInfo, Plan plan);
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingInfo.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingInfo.java
new file mode 100644
index 00000000000..ba6f1939fc5
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingInfo.java
@@ -0,0 +1,14 @@
+// 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.api.integration.pricing;
+
+import java.math.BigDecimal;
+
+import static java.math.BigDecimal.ZERO;
+
+public record PricingInfo(SupportLevel supportLevel, BigDecimal committedHourlyAmount) {
+
+ public enum SupportLevel { BASIC, COMMERCIAL, ENTERPRISE }
+
+ public static PricingInfo empty() { return new PricingInfo(SupportLevel.COMMERCIAL, ZERO); }
+
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/package-info.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/package-info.java
new file mode 100644
index 00000000000..649ab2a80f4
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/package-info.java
@@ -0,0 +1,5 @@
+// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+package com.yahoo.vespa.hosted.controller.api.integration.pricing;
+
+import com.yahoo.osgi.annotation.ExportPackage;
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";
+ }
+
+}