diff options
author | Øyvind Grønnesby <oyving@verizonmedia.com> | 2021-05-10 11:54:24 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-10 11:54:24 +0200 |
commit | 257e0e2fca7b0ff0a15210909116162c925a11b7 (patch) | |
tree | 6ca33a074cd23221e0d9908764b9065115732d79 | |
parent | 0e795977b2072d5504b1a45adffa7d2bdfed7fc2 (diff) | |
parent | 93cbea53ec4aac9861d1f64182d04f3e6d4ee2f2 (diff) |
Merge pull request #17748 from vespa-engine/ogronnesby/billing-api-v2
Some API changes for Billing API
10 files changed, 555 insertions, 38 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java index 39d974378b4..f8ef2958f63 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java @@ -6,6 +6,8 @@ import com.yahoo.config.provision.TenantName; import com.yahoo.config.provision.zone.ZoneId; import java.math.BigDecimal; +import java.time.Clock; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; @@ -325,9 +327,10 @@ public class Invoice { this.history = history; } - public static StatusHistory open() { + public static StatusHistory open(Clock clock) { + var now = clock.instant().atZone(ZoneOffset.UTC); return new StatusHistory( - new TreeMap<>(Map.of(ZonedDateTime.now(), "OPEN")) + new TreeMap<>(Map.of(now, "OPEN")) ); } diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java index b24d532d4a3..535f344d352 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/MockBillingController.java @@ -5,7 +5,9 @@ import com.yahoo.config.provision.TenantName; import com.yahoo.vespa.hosted.controller.api.integration.user.User; import java.math.BigDecimal; +import java.time.Clock; import java.time.LocalDate; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Collection; @@ -21,6 +23,7 @@ import java.util.stream.Collectors; */ public class MockBillingController implements BillingController { + private final Clock clock; Map<TenantName, PlanId> plans = new HashMap<>(); Map<TenantName, PaymentInstrument> activeInstruments = new HashMap<>(); Map<TenantName, List<Invoice>> committedInvoices = new HashMap<>(); @@ -28,6 +31,10 @@ public class MockBillingController implements BillingController { Map<TenantName, List<Invoice.LineItem>> unusedLineItems = new HashMap<>(); Map<TenantName, CollectionMethod> collectionMethod = new HashMap<>(); + public MockBillingController(Clock clock) { + this.clock = clock; + } + @Override public PlanId getPlan(TenantName tenant) { return plans.getOrDefault(tenant, PlanId.from("trial")); @@ -63,7 +70,7 @@ public class MockBillingController implements BillingController { .add(new Invoice( invoiceId, tenant, - Invoice.StatusHistory.open(), + Invoice.StatusHistory.open(clock), List.of(), startTime, endTime @@ -104,10 +111,11 @@ public class MockBillingController implements BillingController { @Override public void updateInvoiceStatus(Invoice.Id invoiceId, String agent, String status) { + var now = clock.instant().atZone(ZoneOffset.UTC); committedInvoices.values().stream() .flatMap(List::stream) .filter(invoice -> invoiceId.equals(invoice.id())) - .forEach(invoice -> invoice.statusHistory().history.put(ZonedDateTime.now(), status)); + .forEach(invoice -> invoice.statusHistory().history.put(now, status)); } @Override @@ -192,6 +200,8 @@ public class MockBillingController implements BillingController { } private Invoice emptyInvoice() { - return new Invoice(Invoice.Id.of("empty"), TenantName.defaultName(), Invoice.StatusHistory.open(), List.of(), ZonedDateTime.now(), ZonedDateTime.now()); + var start = clock.instant().atZone(ZoneOffset.UTC); + var end = clock.instant().atZone(ZoneOffset.UTC); + return new Invoice(Invoice.Id.of("empty"), TenantName.defaultName(), Invoice.StatusHistory.open(clock), List.of(), start, end); } } 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 d052a000860..9f6c0a79455 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 @@ -78,6 +78,11 @@ enum PathGroup { billingList(Matcher.tenant, "/billing/v1/tenant/{tenant}/billing/{*}"), + billing(Matcher.tenant, + "/billing/v2/tenant/{tenant}/{*}"), + + accountant("/billing/v2/accountant/{*}"), + applicationKeys(Matcher.tenant, Matcher.application, "/application/v4/tenant/{tenant}/application/{application}/key/"), @@ -283,6 +288,7 @@ enum PathGroup { PathGroup.billingInstrument, PathGroup.billingList, PathGroup.billingPlan, + PathGroup.billing, PathGroup.hostedAccountant ); } diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java index b48e786c178..ee5f1d806ab 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java @@ -174,7 +174,7 @@ enum Policy { /** Ability to update tenant payment instrument */ planUpdate(Privilege.grant(Action.update) - .on(PathGroup.billingPlan) + .on(PathGroup.billingPlan, PathGroup.billing) .in(SystemName.PublicCd, SystemName.Public)), /** Ability to update tenant collection method */ @@ -185,12 +185,12 @@ enum Policy { /** Read the generated bills */ billingInformationRead(Privilege.grant(Action.read) - .on(PathGroup.billingList) + .on(PathGroup.billingList, PathGroup.billing) .in(SystemName.PublicCd, SystemName.Public)), /** Invoice management */ hostedAccountant(Privilege.grant(Action.all()) - .on(PathGroup.hostedAccountant) + .on(PathGroup.hostedAccountant, PathGroup.accountant) .in(SystemName.PublicCd, SystemName.Public)), /** Listing endpoint certificate request info */ 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\"}"); + } +} |