summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/MockPricingController.java14
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PriceInformation.java3
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/pricing/PricingInfo.java2
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java1
-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
7 files changed, 262 insertions, 3 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
index d703af78246..871f51689d5 100644
--- 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
@@ -8,13 +8,25 @@ import com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingControll
import com.yahoo.vespa.hosted.controller.api.integration.pricing.PricingInfo;
import java.math.BigDecimal;
+import java.math.RoundingMode;
import java.util.List;
public class MockPricingController implements PricingController {
@Override
public PriceInformation price(List<ClusterResources> clusterResources, PricingInfo pricingInfo, Plan plan) {
- return new PriceInformation(new BigDecimal(2 * clusterResources.size()), BigDecimal.ZERO);
+ BigDecimal listPrice = BigDecimal.valueOf(clusterResources.stream()
+ .mapToDouble(resources -> resources.nodes() *
+ (resources.nodeResources().vcpu() * 1000 +
+ resources.nodeResources().memoryGb() * 100 +
+ resources.nodeResources().diskGb() * 10))
+ .sum())
+ .setScale(2, RoundingMode.HALF_UP);
+ BigDecimal volumeDiscount = new BigDecimal("-5.00");
+ BigDecimal committedAmountDiscount = new BigDecimal("0.00");
+ BigDecimal enclaveDiscount = new BigDecimal("0.00");
+ BigDecimal totalAmount = listPrice.add(volumeDiscount);
+ return new PriceInformation(listPrice, volumeDiscount, committedAmountDiscount, enclaveDiscount, totalAmount);
}
}
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
index 3a7769a3d8e..2a6ecf87180 100644
--- 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
@@ -3,6 +3,7 @@ package com.yahoo.vespa.hosted.controller.api.integration.pricing;
import java.math.BigDecimal;
-public record PriceInformation(BigDecimal listPrice, BigDecimal volumeDiscount) {
+public record PriceInformation(BigDecimal listPrice, BigDecimal volumeDiscount, BigDecimal committedAmountDiscount,
+ BigDecimal enclaveDiscount, BigDecimal totalAmount) {
}
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
index 9cc35db23a0..938991e2ed7 100644
--- 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
@@ -3,7 +3,7 @@ package com.yahoo.vespa.hosted.controller.api.integration.pricing;
public record PricingInfo(boolean enclave, SupportLevel supportLevel, double committedHourlyAmount) {
- public enum SupportLevel { STANDARD, COMMERCIAL, ENTERPRISE }
+ public enum SupportLevel { BASIC, COMMERCIAL, ENTERPRISE }
public static PricingInfo empty() { return new PricingInfo(false, SupportLevel.COMMERCIAL, 0); }
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 c7b3c4ebf25..52900f83203 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
@@ -234,6 +234,7 @@ enum PathGroup {
"/badge/v1/{*}", // Badges for deployment jobs.
"/zone/v1/{*}", // Lists environment and regions.
"/cli/v1/{*}", // Public information for Vespa CLI.
+ "/pricing/v1/{*}", // Pricing information
"/.well-known/{*}"),
/** Paths used for deploying system-wide feature flags. */
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);
+ }
+
+}