diff options
author | Ola Aunrønning <ola.aunroe@gmail.com> | 2023-10-20 18:13:27 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-10-20 18:13:27 +0200 |
commit | 16b0ac7203d6433e631de558e30622b297879acb (patch) | |
tree | b1ecbe268a94310302c88b9520d88e08d027622c /controller-server | |
parent | 46bd10fe1fbbbbf388155bf72e73ab26fbd0bfab (diff) |
Revert "Move PricingApiHandler and related code to internal repo"
Diffstat (limited to 'controller-server')
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"; + } + +} |