summaryrefslogtreecommitdiffstats
path: root/controller-server
diff options
context:
space:
mode:
authorØyvind Grønnesby <oyving@verizonmedia.com>2021-05-10 11:54:24 +0200
committerGitHub <noreply@github.com>2021-05-10 11:54:24 +0200
commit257e0e2fca7b0ff0a15210909116162c925a11b7 (patch)
tree6ca33a074cd23221e0d9908764b9065115732d79 /controller-server
parent0e795977b2072d5504b1a45adffa7d2bdfed7fc2 (diff)
parent93cbea53ec4aac9861d1f64182d04f3e6d4ee2f2 (diff)
Merge pull request #17748 from vespa-engine/ogronnesby/billing-api-v2
Some API changes for Billing API
Diffstat (limited to 'controller-server')
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java26
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java352
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/CsvResponse.java33
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java2
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java7
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java138
6 files changed, 528 insertions, 30 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java
index c56c2e93f65..daa84f4700c 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandler.java
@@ -26,14 +26,11 @@ import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingControll
import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId;
import com.yahoo.vespa.hosted.controller.tenant.Tenant;
import com.yahoo.yolean.Exceptions;
-import org.apache.commons.csv.CSVFormat;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.ForbiddenException;
import javax.ws.rs.NotFoundException;
import java.io.IOException;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
import java.math.BigDecimal;
import java.security.Principal;
import java.time.LocalDate;
@@ -482,27 +479,4 @@ public class BillingApiHandler extends LoggingRequestHandler {
.count() > 0;
}
- private static class CsvResponse extends HttpResponse {
- private final String[] header;
- private final List<Object[]> rows;
-
- CsvResponse(String[] header, List<Object[]> rows) {
- super(200);
- this.header = header;
- this.rows = rows;
- }
-
- @Override
- public void render(OutputStream outputStream) throws IOException {
- var writer = new OutputStreamWriter(outputStream);
- var printer = CSVFormat.DEFAULT.withRecordSeparator('\n').withHeader(this.header).print(writer);
- for (var row : this.rows) printer.printRecord(row);
- printer.flush();
- }
-
- @Override
- public String getContentType() {
- return "text/csv; encoding=utf-8";
- }
- }
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java
new file mode 100644
index 00000000000..bfcefecba0c
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2.java
@@ -0,0 +1,352 @@
+package com.yahoo.vespa.hosted.controller.restapi.billing;
+
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.container.jdisc.HttpResponse;
+import com.yahoo.container.jdisc.LoggingRequestHandler;
+import com.yahoo.restapi.MessageResponse;
+import com.yahoo.restapi.RestApi;
+import com.yahoo.restapi.RestApiException;
+import com.yahoo.restapi.RestApiRequestHandler;
+import com.yahoo.restapi.SlimeJsonResponse;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Inspector;
+import com.yahoo.slime.Slime;
+import com.yahoo.slime.SlimeUtils;
+import com.yahoo.slime.Type;
+import com.yahoo.vespa.hosted.controller.ApplicationController;
+import com.yahoo.vespa.hosted.controller.Controller;
+import com.yahoo.vespa.hosted.controller.TenantController;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.CollectionMethod;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.Invoice;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId;
+import com.yahoo.vespa.hosted.controller.api.role.Role;
+import com.yahoo.vespa.hosted.controller.api.role.SecurityContext;
+import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
+import com.yahoo.vespa.hosted.controller.tenant.Tenant;
+
+import javax.ws.rs.BadRequestException;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.Comparator;
+import java.util.Optional;
+import java.util.List;
+
+/**
+ * @author ogronnesby
+ */
+public class BillingApiHandlerV2 extends RestApiRequestHandler<BillingApiHandlerV2> {
+ private static final String[] CSV_INVOICE_HEADER = new String[]{ "ID", "Tenant", "From", "To", "CpuHours", "MemoryHours", "DiskHours", "Cpu", "Memory", "Disk", "Additional" };
+
+ private final ApplicationController applications;
+ private final TenantController tenants;
+ private final BillingController billing;
+ private final Clock clock;
+
+ public BillingApiHandlerV2(LoggingRequestHandler.Context context, Controller controller) {
+ super(context, BillingApiHandlerV2::createRestApi);
+ this.applications = controller.applications();
+ this.tenants = controller.tenants();
+ this.billing = controller.serviceRegistry().billingController();
+ this.clock = controller.serviceRegistry().clock();
+ }
+
+ private static RestApi createRestApi(BillingApiHandlerV2 self) {
+ return RestApi.builder()
+ /*
+ * This is the API that is available to tenants to view their status
+ */
+ .addRoute(RestApi.route("/billing/v2/tenant/{tenant}")
+ .get(self::tenant)
+ .patch(Slime.class, self::patchTenant))
+ .addRoute(RestApi.route("/billing/v2/tenant/{tenant}/usage")
+ .get(self::tenantUsage))
+ .addRoute(RestApi.route("/billing/v2/tenant/{tenant}/bill")
+ .get(self::tenantInvoiceList))
+ .addRoute(RestApi.route("/billing/v2/tenant/{tenant}/bill/{invoice}")
+ .get(self::tenantInvoice))
+ /*
+ * This is the API that is created for accountant role in Vespa Cloud
+ */
+ .addRoute(RestApi.route("/billing/v2/accountant")
+ .get(self::accountant))
+ .addRoute(RestApi.route("/billing/v2/accountant/preview/tenant/{tenant}")
+ .get(self::previewBill)
+ .post(Slime.class, self::createBill))
+ /*
+ * Utility - map Slime.class => SlimeJsonResponse
+ */
+ .addRequestMapper(Slime.class, BillingApiHandlerV2::slimeRequestMapper)
+ .addResponseMapper(Slime.class, BillingApiHandlerV2::slimeResponseMapper)
+ .build();
+ }
+
+ // ---------- TENANT API ----------
+
+ private Slime tenant(RestApi.RequestContext requestContext) {
+ var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant"));
+ var tenant = tenants.require(tenantName, CloudTenant.class);
+
+ var plan = billing.getPlan(tenant.name());
+ var collectionMethod = billing.getCollectionMethod(tenant.name());
+
+ var response = new Slime();
+ var cursor = response.setObject();
+ cursor.setString("tenant", tenant.name().value());
+ cursor.setString("plan", plan.value());
+ cursor.setString("collection", collectionMethod.name());
+ return response;
+ }
+
+ private Slime patchTenant(RestApi.RequestContext requestContext, Slime body) {
+ var security = requestContext.attributes().get(SecurityContext.ATTRIBUTE_NAME)
+ .map(SecurityContext.class::cast)
+ .orElseThrow(() -> new RestApiException.Forbidden("Must be logged in"));
+
+ var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant"));
+ var tenant = tenants.require(tenantName, CloudTenant.class);
+
+ var newPlan = body.get().field("plan");
+ var newCollection = body.get().field("collection");
+
+ if (newPlan.valid() && newPlan.type() == Type.STRING) {
+ var planId = PlanId.from(newPlan.asString());
+ var hasDeployments = tenantHasDeployments(tenant.name());
+ var result = billing.setPlan(tenant.name(), planId, hasDeployments);
+ if (! result.isSuccess()) {
+ throw new RestApiException.Forbidden(result.getErrorMessage().get());
+ }
+ }
+
+ if (newCollection.valid() && newCollection.type() == Type.STRING) {
+ if (security.roles().contains(Role.hostedAccountant())) {
+ var collection = CollectionMethod.valueOf(newCollection.asString());
+ billing.setCollectionMethod(tenant.name(), collection);
+ } else {
+ throw new RestApiException.Forbidden("Only accountant can change billing method");
+ }
+ }
+
+ var response = new Slime();
+ var cursor = response.setObject();
+ cursor.setString("tenant", tenant.name().value());
+ cursor.setString("plan", billing.getPlan(tenant.name()).value());
+ cursor.setString("collection", billing.getCollectionMethod(tenant.name()).name());
+ return response;
+ }
+
+ private Slime tenantInvoiceList(RestApi.RequestContext requestContext) {
+ var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant"));
+ var tenant = tenants.require(tenantName, CloudTenant.class);
+
+ var slime = new Slime();
+ invoicesSummaryToSlime(slime.setObject().setArray("invoices"), billing.getInvoicesForTenant(tenant.name()));
+ return slime;
+ }
+
+ private HttpResponse tenantInvoice(RestApi.RequestContext requestContext) {
+ var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant"));
+ var tenant = tenants.require(tenantName, CloudTenant.class);
+ var invoiceId = requestContext.pathParameters().getStringOrThrow("invoice");
+ var format = requestContext.queryParameters().getString("format").orElse("json");
+
+ var invoice = billing.getInvoicesForTenant(tenant.name()).stream()
+ .filter(inv -> inv.id().value().equals(invoiceId))
+ .findAny()
+ .orElseThrow(RestApiException.NotFound::new);
+
+ if (format.equals("json")) {
+ var slime = new Slime();
+ toSlime(slime.setObject(), invoice);
+ return new SlimeJsonResponse(slime);
+ }
+
+ if (format.equals("csv")) {
+ var csv = toCsv(invoice);
+ return new CsvResponse(CSV_INVOICE_HEADER, csv);
+ }
+
+ throw new RestApiException.BadRequest("Unknown format: " + format);
+ }
+
+ private boolean tenantHasDeployments(TenantName tenant) {
+ return applications.asList(tenant).stream()
+ .flatMap(app -> app.instances().values().stream())
+ .mapToLong(instance -> instance.deployments().size())
+ .sum() > 0;
+ }
+
+ private Slime tenantUsage(RestApi.RequestContext requestContext) {
+ var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant"));
+ var tenant = tenants.require(tenantName, CloudTenant.class);
+ var untilAt = untilParameter(requestContext);
+ var usage = billing.createUncommittedInvoice(tenant.name(), untilAt.atZone(ZoneOffset.UTC).toLocalDate());
+
+ var slime = new Slime();
+ usageToSlime(slime.setObject(), usage);
+ return slime;
+ }
+
+ // --------- ACCOUNTANT API ----------
+
+ private Slime accountant(RestApi.RequestContext requestContext) {
+ var untilAt = untilParameter(requestContext);
+ var usagePerTenant = billing.createUncommittedInvoices(untilAt.atZone(ZoneOffset.UTC).toLocalDate());
+
+ var response = new Slime();
+ var tenantsResponse = response.setObject().setArray("tenants");
+ tenants.asList().stream().sorted(Comparator.comparing(Tenant::name)).forEach(tenant -> {
+ var usage = Optional.ofNullable(usagePerTenant.get(tenant.name()));
+ var tenantResponse = tenantsResponse.addObject();
+ tenantResponse.setString("tenant", tenant.name().value());
+ tenantResponse.setString("plan", billing.getPlan(tenant.name()).value());
+ tenantResponse.setString("collection", billing.getCollectionMethod(tenant.name()).name());
+ tenantResponse.setString("lastBill", usage.map(Invoice::getStartTime).map(DateTimeFormatter.ISO_DATE::format).orElse(null));
+ tenantResponse.setString("unbilled", usage.map(Invoice::sum).map(BigDecimal::toPlainString).orElse("0.00"));
+ });
+
+ return response;
+ }
+
+ private Slime previewBill(RestApi.RequestContext requestContext) {
+ var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant"));
+ var tenant = tenants.require(tenantName, CloudTenant.class);
+ var untilAt = untilParameter(requestContext);
+
+ var usage = billing.createUncommittedInvoice(tenant.name(), untilAt.atZone(ZoneOffset.UTC).toLocalDate());
+
+ var slime = new Slime();
+ toSlime(slime.setObject(), usage);
+ return slime;
+ }
+
+ private HttpResponse createBill(RestApi.RequestContext requestContext, Slime slime) {
+ var body = slime.get();
+ var security = requestContext.attributes().get(SecurityContext.ATTRIBUTE_NAME)
+ .map(SecurityContext.class::cast)
+ .orElseThrow(() -> new RestApiException.Forbidden("Must be logged in"));
+
+ var tenantName = TenantName.from(requestContext.pathParameters().getStringOrThrow("tenant"));
+ var tenant = tenants.require(tenantName, CloudTenant.class);
+
+ var startAt = LocalDate.parse(getInspectorFieldOrThrow(body, "from")).atStartOfDay(ZoneOffset.UTC);
+ var endAt = LocalDate.parse(getInspectorFieldOrThrow(body, "to")).atStartOfDay(ZoneOffset.UTC);
+
+ var invoiceId = billing.createInvoiceForPeriod(tenant.name(), startAt, endAt, security.principal().getName());
+
+ // TODO: Make a redirect to the bill itself
+ return new MessageResponse("Created bill " + invoiceId.value());
+ }
+
+
+ // --------- INVOICE RENDERING ----------
+
+ private void invoicesSummaryToSlime(Cursor slime, List<Invoice> invoices) {
+ invoices.forEach(invoice -> invoiceSummaryToSlime(slime.addObject(), invoice));
+ }
+
+ private void invoiceSummaryToSlime(Cursor slime, Invoice invoice) {
+ slime.setString("id", invoice.id().value());
+ slime.setString("from", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE));
+ slime.setString("to", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE));
+ slime.setString("total", invoice.sum().toString());
+ slime.setString("status", invoice.status());
+ }
+
+ private void usageToSlime(Cursor slime, Invoice invoice) {
+ slime.setString("from", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE));
+ slime.setString("to", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE));
+ slime.setString("total", invoice.sum().toString());
+ toSlime(slime.setArray("items"), invoice.lineItems());
+ }
+
+ private void toSlime(Cursor slime, Invoice invoice) {
+ slime.setString("id", invoice.id().value());
+ slime.setString("from", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE));
+ slime.setString("to", invoice.getStartTime().format(DateTimeFormatter.ISO_LOCAL_DATE));
+ slime.setString("total", invoice.sum().toString());
+ slime.setString("status", invoice.status());
+ toSlime(slime.setArray("statusHistory"), invoice.statusHistory());
+ toSlime(slime.setArray("items"), invoice.lineItems());
+ }
+
+ private void toSlime(Cursor slime, Invoice.StatusHistory history) {
+ history.getHistory().forEach((key, value) -> {
+ var c = slime.addObject();
+ c.setString("at", key.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
+ c.setString("status", value);
+ });
+ }
+
+ private void toSlime(Cursor slime, List<Invoice.LineItem> items) {
+ items.forEach(item -> toSlime(slime.addObject(), item));
+ }
+
+ private void toSlime(Cursor slime, Invoice.LineItem item) {
+ slime.setString("id", item.id());
+ slime.setString("description", item.description());
+ slime.setString("amount",item.amount().toString());
+ slime.setString("plan", item.plan());
+ slime.setString("planName", billing.getPlanDisplayName(PlanId.from(item.plan())));
+
+ item.applicationId().ifPresent(appId -> {
+ slime.setString("application", appId.application().value());
+ slime.setString("instance", appId.instance().value());
+ });
+
+ item.zoneId().ifPresent(z -> slime.setString("zone", z.value()));
+
+ toSlime(slime.setObject("cpu"), item.getCpuHours(), item.getCpuCost());
+ toSlime(slime.setObject("memory"), item.getMemoryHours(), item.getMemoryCost());
+ toSlime(slime.setObject("disk"), item.getDiskHours(), item.getDiskCost());
+ }
+
+ private void toSlime(Cursor slime, Optional<BigDecimal> hours, Optional<BigDecimal> cost) {
+ hours.ifPresent(h -> slime.setString("hours", h.toString()));
+ cost.ifPresent(c -> slime.setString("cost", c.toString()));
+ }
+
+ private List<Object[]> toCsv(Invoice invoice) {
+ return List.<Object[]>of(new Object[]{
+ invoice.id().value(), invoice.tenant().value(),
+ invoice.getStartTime().format(DateTimeFormatter.ISO_DATE),
+ invoice.getEndTime().format(DateTimeFormatter.ISO_DATE),
+ invoice.sumCpuHours(), invoice.sumMemoryHours(), invoice.sumDiskHours(),
+ invoice.sumCpuCost(), invoice.sumMemoryCost(), invoice.sumDiskCost(),
+ invoice.sumAdditionalCost()
+ });
+ }
+
+ // ---------- END INVOICE RENDERING ----------
+
+ private Instant untilParameter(RestApi.RequestContext ctx) {
+ return ctx.queryParameters().getString("until")
+ .map(LocalDate::parse)
+ .map(date -> date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant())
+ .orElseGet(clock::instant);
+ }
+
+ private static String getInspectorFieldOrThrow(Inspector inspector, String field) {
+ if (!inspector.field(field).valid())
+ throw new BadRequestException("Field " + field + " cannot be null");
+ return inspector.field(field).asString();
+ }
+
+ private static Optional<Slime> slimeRequestMapper(RestApi.RequestContext requestContext) {
+ try {
+ return Optional.of(SlimeUtils.jsonToSlime(requestContext.requestContentOrThrow().content().readAllBytes()));
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Could not parse JSON input");
+ }
+ }
+
+ private static HttpResponse slimeResponseMapper(RestApi.RequestContext ctx, Slime slime) {
+ return new SlimeJsonResponse(slime);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/CsvResponse.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/CsvResponse.java
new file mode 100644
index 00000000000..5aa993f2727
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/billing/CsvResponse.java
@@ -0,0 +1,33 @@
+package com.yahoo.vespa.hosted.controller.restapi.billing;
+
+import com.yahoo.container.jdisc.HttpResponse;
+import org.apache.commons.csv.CSVFormat;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.util.List;
+
+class CsvResponse extends HttpResponse {
+ private final String[] header;
+ private final List<Object[]> rows;
+
+ CsvResponse(String[] header, List<Object[]> rows) {
+ super(200);
+ this.header = header;
+ this.rows = rows;
+ }
+
+ @Override
+ public void render(OutputStream outputStream) throws IOException {
+ var writer = new OutputStreamWriter(outputStream);
+ var printer = CSVFormat.DEFAULT.withRecordSeparator('\n').withHeader(this.header).print(writer);
+ for (var row : this.rows) printer.printRecord(row);
+ printer.flush();
+ }
+
+ @Override
+ public String getContentType() {
+ return "text/csv; encoding=utf-8";
+ }
+}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java
index c11c6ba155a..702ce83d116 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/integration/ServiceRegistryMock.java
@@ -68,7 +68,7 @@ public class ServiceRegistryMock extends AbstractComponent implements ServiceReg
private final MockRunDataStore mockRunDataStore = new MockRunDataStore();
private final MockResourceTagger mockResourceTagger = new MockResourceTagger();
private final RoleService roleService = new MockRoleService();
- private final BillingController billingController = new MockBillingController();
+ private final BillingController billingController = new MockBillingController(clock);
private final ContainerRegistryMock containerRegistry = new ContainerRegistryMock();
private final NoopTenantSecretService tenantSecretService = new NoopTenantSecretService();
private final ArchiveService archiveService = new MockArchiveService();
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java
index b88715efcc4..88b2b939c48 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerTest.java
@@ -20,6 +20,7 @@ import java.io.File;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.ZoneId;
+import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
@@ -221,8 +222,8 @@ public class BillingApiHandlerTest extends ControllerContainerCloudTest {
assertEquals(CollectionMethod.INVOICE, billingController.getCollectionMethod(tenant));
}
- private Invoice createInvoice() {
- var start = LocalDate.of(2020, 5, 23).atStartOfDay(ZoneId.systemDefault());
+ static Invoice createInvoice() {
+ var start = LocalDate.of(2020, 5, 23).atStartOfDay(ZoneOffset.UTC);
var end = start.plusDays(5);
var statusHistory = new Invoice.StatusHistory(new TreeMap<>(Map.of(start, "OPEN")));
return new Invoice(
@@ -235,7 +236,7 @@ public class BillingApiHandlerTest extends ControllerContainerCloudTest {
);
}
- private Invoice.LineItem createLineItem(ZonedDateTime addedAt) {
+ static Invoice.LineItem createLineItem(ZonedDateTime addedAt) {
return new Invoice.LineItem(
"some-id",
"description",
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java
new file mode 100644
index 00000000000..e733f8e27d6
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/BillingApiHandlerV2Test.java
@@ -0,0 +1,138 @@
+package com.yahoo.vespa.hosted.controller.restapi.billing;
+
+import com.yahoo.application.container.handler.Request;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.test.ManualClock;
+import com.yahoo.vespa.hosted.controller.api.integration.billing.MockBillingController;
+import com.yahoo.vespa.hosted.controller.api.role.Role;
+import com.yahoo.vespa.hosted.controller.restapi.ContainerTester;
+import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerCloudTest;
+import com.yahoo.vespa.hosted.controller.security.Auth0Credentials;
+import com.yahoo.vespa.hosted.controller.security.CloudTenantSpec;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.time.Instant;
+import java.util.Set;
+
+/**
+ * @author ogronnesby
+ */
+public class BillingApiHandlerV2Test extends ControllerContainerCloudTest {
+
+ private static final String responseFiles = "src/test/java/com/yahoo/vespa/hosted/controller/restapi/billing/responses/";
+
+ private static final TenantName tenant = TenantName.from("tenant1");
+ private static final TenantName tenant2 = TenantName.from("tenant2");
+ private static final Set<Role> tenantReader = Set.of(Role.reader(tenant));
+ private static final Set<Role> tenantAdmin = Set.of(Role.administrator(tenant));
+ private static final Set<Role> financeAdmin = Set.of(Role.hostedAccountant());
+
+ private static final String ACCESS_DENIED = "{\n" +
+ " \"code\" : 403,\n" +
+ " \"message\" : \"Access denied\"\n" +
+ "}";
+
+ private MockBillingController billingController;
+ private ContainerTester tester;
+
+ @Before
+ public void before() {
+ tester = new ContainerTester(container, responseFiles);
+ tester.controller().tenants().create(new CloudTenantSpec(tenant, ""), new Auth0Credentials(() -> "foo", Set.of(Role.hostedOperator())));
+ var clock = (ManualClock) tester.controller().serviceRegistry().clock();
+ clock.setInstant(Instant.parse("2021-04-13T00:00:00Z"));
+ billingController = (MockBillingController) tester.serviceRegistry().billingController();
+ billingController.addInvoice(tenant, BillingApiHandlerTest.createInvoice(), true);
+ }
+
+ @Override
+ protected String variablePartXml() {
+ return " <component id='com.yahoo.vespa.hosted.controller.security.CloudAccessControlRequests'/>\n" +
+ " <component id='com.yahoo.vespa.hosted.controller.security.CloudAccessControl'/>\n" +
+
+ " <handler id='com.yahoo.vespa.hosted.controller.restapi.billing.BillingApiHandlerV2'>\n" +
+ " <binding>http://*/billing/v2/*</binding>\n" +
+ " </handler>\n" +
+
+ " <http>\n" +
+ " <server id='default' port='8080' />\n" +
+ " <filtering>\n" +
+ " <request-chain id='default'>\n" +
+ " <filter id='com.yahoo.vespa.hosted.controller.restapi.filter.ControllerAuthorizationFilter'/>\n" +
+ " <binding>http://*/*</binding>\n" +
+ " </request-chain>\n" +
+ " </filtering>\n" +
+ " </http>\n";
+ }
+
+ @Test
+ public void require_tenant_info() {
+ var request = request("/billing/v2/tenant/" + tenant.value()).roles(tenantReader);
+ tester.assertResponse(request, "{\"tenant\":\"tenant1\",\"plan\":\"trial\",\"collection\":\"AUTO\"}");
+ }
+
+ @Test
+ public void require_admin_for_update_plan() {
+ var request = request("/billing/v2/tenant/" + tenant.value(), Request.Method.PATCH)
+ .data("{\"plan\": \"pay-as-you-go\"}");
+
+ var forbidden = request.roles(tenantReader);
+ tester.assertResponse(forbidden, ACCESS_DENIED, 403);
+ var success = request.roles(tenantAdmin);
+ tester.assertResponse(success, "{\"tenant\":\"tenant1\",\"plan\":\"pay-as-you-go\",\"collection\":\"AUTO\"}");
+ }
+
+ @Test
+ public void require_accountant_for_update_collection() {
+ var request = request("/billing/v2/tenant/" + tenant.value(), Request.Method.PATCH)
+ .data("{\"collection\": \"INVOICE\"}");
+
+ var forbidden = request.roles(tenantAdmin);
+ tester.assertResponse(forbidden, "{\"error-code\":\"FORBIDDEN\",\"message\":\"Only accountant can change billing method\"}", 403);
+
+ var success = request.roles(financeAdmin);
+ tester.assertResponse(success, "{\"tenant\":\"tenant1\",\"plan\":\"trial\",\"collection\":\"INVOICE\"}");
+ }
+
+ @Test
+ public void require_tenant_usage() {
+ var request = request("/billing/v2/tenant/" + tenant + "/usage").roles(tenantReader);
+ tester.assertResponse(request, "{\"from\":\"2021-04-13\",\"to\":\"2021-04-13\",\"total\":\"0.00\",\"items\":[]}");
+ }
+
+ @Test
+ public void require_tenant_invoice() {
+ var listRequest = request("/billing/v2/tenant/" + tenant + "/bill").roles(tenantReader);
+ tester.assertResponse(listRequest, "{\"invoices\":[{\"id\":\"id-1\",\"from\":\"2020-05-23\",\"to\":\"2020-05-23\",\"total\":\"123.00\",\"status\":\"OPEN\"}]}");
+
+ var singleRequest = request("/billing/v2/tenant/" + tenant + "/bill/id-1").roles(tenantReader);
+ tester.assertResponse(singleRequest, "{\"id\":\"id-1\",\"from\":\"2020-05-23\",\"to\":\"2020-05-23\",\"total\":\"123.00\",\"status\":\"OPEN\",\"statusHistory\":[{\"at\":\"2020-05-23T00:00:00Z\",\"status\":\"OPEN\"}],\"items\":[{\"id\":\"some-id\",\"description\":\"description\",\"amount\":\"123.00\",\"plan\":\"some-plan\",\"planName\":\"Plan with id: some-plan\",\"cpu\":{},\"memory\":{},\"disk\":{}}]}");
+ }
+
+ @Test
+ public void require_accountant_summary() {
+ var tenantRequest = request("/billing/v2/accountant").roles(tenantReader);
+ tester.assertResponse(tenantRequest, "{\n" +
+ " \"code\" : 403,\n" +
+ " \"message\" : \"Access denied\"\n" +
+ "}", 403);
+
+ var accountantRequest = request("/billing/v2/accountant").roles(Role.hostedAccountant());
+ tester.assertResponse(accountantRequest, "{\"tenants\":[{\"tenant\":\"tenant1\",\"plan\":\"trial\",\"collection\":\"AUTO\",\"lastBill\":null,\"unbilled\":\"0.00\"}]}");
+ }
+
+ @Test
+ public void require_accountant_tenant_preview() {
+ var accountantRequest = request("/billing/v2/accountant/preview/tenant/tenant1").roles(Role.hostedAccountant());
+ tester.assertResponse(accountantRequest, "{\"id\":\"empty\",\"from\":\"2021-04-13\",\"to\":\"2021-04-13\",\"total\":\"0.00\",\"status\":\"OPEN\",\"statusHistory\":[{\"at\":\"2021-04-13T00:00:00Z\",\"status\":\"OPEN\"}],\"items\":[]}");
+ }
+
+ @Test
+ public void require_accountant_tenant_bill() {
+ var accountantRequest = request("/billing/v2/accountant/preview/tenant/tenant1", Request.Method.POST)
+ .roles(Role.hostedAccountant())
+ .data("{\"from\": \"2020-05-01\",\"to\": \"2020-06-01\"}");
+ tester.assertResponse(accountantRequest, "{\"message\":\"Created bill id-123\"}");
+ }
+}