diff options
author | Harald Musum <musum@verizonmedia.com> | 2023-10-10 14:43:07 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-10-10 14:43:07 +0200 |
commit | 050149b3cc0702299159540144635767379a6e0d (patch) | |
tree | f001c716ccbadc8a7ff35d22bc8ea4e346db92a8 /controller-server | |
parent | b5e9551aef5230904d726b47578b4cb6b1fcfe2d (diff) | |
parent | d8b9d80eb27523f9b4374f90a49051ed26818285 (diff) |
Merge pull request #28832 from vespa-engine/hmusum/add-handler-for-pricing-api
Add handler for pricing API
Diffstat (limited to 'controller-server')
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); + } + +} |