diff options
author | Øyvind Grønnesby <oyving@verizonmedia.com> | 2021-10-19 15:56:22 +0200 |
---|---|---|
committer | Øyvind Grønnesby <oyving@verizonmedia.com> | 2021-10-19 15:56:22 +0200 |
commit | fa3677633b2f7a0be4a955471b739a6c1896591d (patch) | |
tree | dec8238148b8f509c22c2c60e8c0323b65206434 /controller-api/src | |
parent | 680a711d800af6c60d87b33388833f3a24081009 (diff) |
Revert "Merge pull request #19502 from vespa-engine/revert-19496-revert-19485-revert-18572-ogronnesby/billing-service"
This reverts commit 471db5f7a1bc16bd5e8be9b80a5f06ee28967b16, reversing
changes made to 11a50513dc58eeb52d290fe3dc1d826f27069d26.
Diffstat (limited to 'controller-api/src')
11 files changed, 771 insertions, 46 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java index 4714b74ba94..93e3f5585c8 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/ServiceRegistry.java @@ -7,6 +7,8 @@ import com.yahoo.vespa.hosted.controller.api.integration.aws.RoleService; import com.yahoo.vespa.hosted.controller.api.integration.aws.CloudEventFetcher; import com.yahoo.vespa.hosted.controller.api.integration.aws.ResourceTagger; import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingController; +import com.yahoo.vespa.hosted.controller.api.integration.billing.BillingDatabaseClient; +import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanRegistry; import com.yahoo.vespa.hosted.controller.api.integration.certificates.EndpointCertificateProvider; import com.yahoo.vespa.hosted.controller.api.integration.certificates.EndpointCertificateValidator; import com.yahoo.vespa.hosted.controller.api.integration.configserver.ConfigServer; @@ -25,6 +27,7 @@ import com.yahoo.vespa.hosted.controller.api.integration.organization.OwnershipI import com.yahoo.vespa.hosted.controller.api.integration.organization.SystemMonitor; import com.yahoo.vespa.hosted.controller.api.integration.resource.CostReportConsumer; import com.yahoo.vespa.hosted.controller.api.integration.resource.MeteringClient; +import com.yahoo.vespa.hosted.controller.api.integration.resource.ResourceDatabaseClient; import com.yahoo.vespa.hosted.controller.api.integration.routing.GlobalRoutingService; import com.yahoo.vespa.hosted.controller.api.integration.secrets.TenantSecretService; import com.yahoo.vespa.hosted.controller.api.integration.vcmr.ChangeRequestClient; @@ -88,6 +91,10 @@ public interface ServiceRegistry { BillingController billingController(); + ResourceDatabaseClient resourceDatabase(); + + BillingDatabaseClient billingDatabase(); + ContainerRegistry containerRegistry(); TenantSecretService tenantSecretService(); @@ -99,4 +106,6 @@ public interface ServiceRegistry { AccessControlService accessControlService(); HorizonClient horizonClient(); + + PlanRegistry planRegistry(); } 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/Bill.java index 3789021ae8e..d1af5b428de 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/Bill.java @@ -20,18 +20,18 @@ import java.util.function.Function; /** - * An Invoice is an identifier with a status (with history) and line items. A line item is the meat and - * potatoes of the content of the invoice, and are a history of items. Most line items are connected to + * An Bill is an identifier with a status (with history) and line items. A line item is the meat and + * potatoes of the content of the bill, and are a history of items. Most line items are connected to * a given deployment in Vespa Cloud, but they can also be manually added to e.g. give a discount or represent * support. * <p> * All line items have a Plan associated with them - which was used to map from utilization to an actual price. * <p> - * The invoice has a status history, but only the latest status is exposed through this API. + * The bill has a status history, but only the latest status is exposed through this API. * * @author ogronnesby */ -public class Invoice { +public class Bill { private static final BigDecimal SCALED_ZERO = new BigDecimal("0.00"); private final Id id; @@ -41,7 +41,7 @@ public class Invoice { private final ZonedDateTime startTime; private final ZonedDateTime endTime; - public Invoice(Id id, TenantName tenant, StatusHistory statusHistory, List<LineItem> lineItems, ZonedDateTime startTime, ZonedDateTime endTime) { + public Bill(Id id, TenantName tenant, StatusHistory statusHistory, List<LineItem> lineItems, ZonedDateTime startTime, ZonedDateTime endTime) { this.id = id; this.tenant = tenant; this.lineItems = List.copyOf(lineItems); @@ -141,8 +141,8 @@ public class Invoice { public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - Id invoiceId = (Id) o; - return value.equals(invoiceId.value); + Id billId = (Id) o; + return value.equals(billId.value); } @Override @@ -152,14 +152,14 @@ public class Invoice { @Override public String toString() { - return "InvoiceId{" + + return "BillId{" + "value='" + value + '\'' + '}'; } } /** - * Represents a chargeable line on an invoice. + * Represents a chargeable line on a bill. */ public static class LineItem { private final String id; diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java index 91916975146..61f8844482c 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingController.java @@ -12,55 +12,112 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +/** + * A service that controls creation of bills based on the resource usage of a tenant, controls the quota for a + * tenant, and controls the plan the tenant is on. + * + * @author ogronnesby + * @author olaa + */ public interface BillingController { + /** + * Get the plan ID for the given tenant. + * This method will not fail if the tenant does not exist, it will return the default plan for that tenant instead. + */ PlanId getPlan(TenantName tenant); + /** + * Return the list of tenants with the given plan. + * @param existing All existing tenants in the system + * @param planId The ID of the plan to filter existing tenants on. + * @return The tenants that have the given plan. + */ List<TenantName> tenantsWithPlan(List<TenantName> existing, PlanId planId); + /** The display name of the given plan */ String getPlanDisplayName(PlanId planId); + /** + * The quota for the given tenant. + * This method will return default quota for tenants that do not exist. + */ Quota getQuota(TenantName tenant); /** + * Set the plan for the current tenant. Checks some pre-conditions to see if the tenant is eligible for the + * given plan. + * @param tenant The name of the tenant. + * @param planId The ID of the plan to change to. + * @param hasDeployments Does the tenant have active deployments. * @return String containing error message if something went wrong. Empty otherwise */ PlanResult setPlan(TenantName tenant, PlanId planId, boolean hasDeployments); - Invoice.Id createInvoiceForPeriod(TenantName tenant, ZonedDateTime startTime, ZonedDateTime endTime, String agent); + /** + * Create a bill of unbilled use for the given tenant in the given time period. + * @param tenant The name of the tenant. + * @param startTime The start of the billing period + * @param endTime The end of the billing period + * @param agent The agent that creates the bill + * @return The ID of the new bill. + */ + Bill.Id createBillForPeriod(TenantName tenant, ZonedDateTime startTime, ZonedDateTime endTime, String agent); - Invoice createUncommittedInvoice(TenantName tenant, LocalDate until); + /** + * Create an unpersisted bill of unbilled use for the given tenant from the end of last bill until the given date. + * This is used to show "unbilled use" in the Console. + * @param tenant The name of the tenant. + * @param until The end date of the unbilled use period. + * @return A bill with the resource use and cost. + */ + Bill createUncommittedBill(TenantName tenant, LocalDate until); - Map<TenantName, Invoice> createUncommittedInvoices(LocalDate until); + /** Run {createUncommittedBill} for all tenants with unbilled use */ + Map<TenantName, Bill> createUncommittedBills(LocalDate until); - List<Invoice.LineItem> getUnusedLineItems(TenantName tenant); + /** Get line items that have been manually added to a tenant, but is not yet part of a bill */ + List<Bill.LineItem> getUnusedLineItems(TenantName tenant); + /** Get the payment instrument for the given tenant */ Optional<PaymentInstrument> getDefaultInstrument(TenantName tenant); + /** Get the auth token needed to talk to payment services */ String createClientToken(String tenant, String userId); + /** Delete a payment instrument from the list of the tenant's instruments */ boolean deleteInstrument(TenantName tenant, String userId, String instrumentId); - void updateInvoiceStatus(Invoice.Id invoiceId, String agent, String status); + /** Change the status of the given bill */ + void updateBillStatus(Bill.Id billId, String agent, String status); + /** Add a line item to the given bill */ void addLineItem(TenantName tenant, String description, BigDecimal amount, String agent); + /** Delete a line item - only available for unused line items */ void deleteLineItem(String lineItemId); + /** Set the given payment instrument as the active instrument for the tenant */ boolean setActivePaymentInstrument(InstrumentOwner paymentInstrument); + /** List the payment instruments from the tenant */ InstrumentList listInstruments(TenantName tenant, String userId); - List<Invoice> getInvoicesForTenant(TenantName tenant); + /** Get all bills for the given tenant */ + List<Bill> getBillsForTenant(TenantName tenant); - List<Invoice> getInvoices(); + /** Get all bills from the system */ + List<Bill> getBills(); + /** Delete billing contact information from the tenant */ void deleteBillingInfo(TenantName tenant, Set<User> users, boolean isPrivileged); + /** Get the bill collection method for the given tenant */ default CollectionMethod getCollectionMethod(TenantName tenant) { return CollectionMethod.NONE; } + /** Set the bill collection method for the given tenant */ default CollectionResult setCollectionMethod(TenantName tenant, CollectionMethod method) { return CollectionResult.error("Method not implemented"); } diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClient.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClient.java new file mode 100644 index 00000000000..4891fe0ffa7 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClient.java @@ -0,0 +1,135 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.api.integration.billing; + +import com.yahoo.config.provision.TenantName; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Interface that talks about bills in the billing API. It is a layer on top of the SQL + * database where we store data about bills. + * + * @author olaa + * @author ogronnesby + */ +public interface BillingDatabaseClient { + + boolean setActivePaymentInstrument(InstrumentOwner paymentInstrument); + + Optional<InstrumentOwner> getDefaultPaymentInstrumentForTenant(TenantName from); + + /** + * Create a completely new Bill in the open state with no LineItems. + * + * @param tenant The name of the tenant the bill is for + * @param agent The agent that created the bill + * @return The Id of the new bill + */ + Bill.Id createBill(TenantName tenant, ZonedDateTime startTime, ZonedDateTime endTime, String agent); + + /** + * Read the given bill from the data source + * + * @param billId The Id of the bill to retrieve + * @return The Bill if it exists, Optional.empty() if not. + */ + Optional<Bill> readBill(Bill.Id billId); + + /** + * Get all bills for a given tenant, ordered by date + * + * @param tenant The name of the tenant + * @return List of all bills ordered by date + */ + List<Bill> readBillsForTenant(TenantName tenant); + + /** + * Read all bills, ordered by date + * @return List of all bills ordered by date + */ + List<Bill> readBills(); + + /** + * Add a line item to an open bill + * + * @param lineItem + * @param billId The optional ID of the bill this line item is for + * @return The Id of the new line item + * @throws RuntimeException if the bill is not in OPEN state + */ + String addLineItem(TenantName tenantName, Bill.LineItem lineItem, Optional<Bill.Id> billId); + + /** + * Set status for the given bill + * + * @param billId The ID of the bill this status is for + * @param agent The agent that added the status + * @param status The new status of the bill + */ + void setStatus(Bill.Id billId, String agent, String status); + + List<Bill.LineItem> getUnusedLineItems(TenantName tenantName); + + /** + * Delete a line item + * This is only allowed if the line item has not yet been associated with an bill + * + * @param lineItemId The ID of the line item + * @throws RuntimeException if the line item is associated with an bill + */ + void deleteLineItem(String lineItemId); + + /** + * Associate all uncommitted line items to a given bill + * This is only allowed if the line item has not already been associated with an bill + * + * @param tenantName The tenant we want to commit line items for + * @param billId The ID of the line item + * @throws RuntimeException if the line item is already associated with an bill + */ + void commitLineItems(TenantName tenantName, Bill.Id billId); + + /** + * Return the plan for the given tenant + * + * @param tenantName The tenant to retrieve the plan for + * @return Optional.of the plan if present in DN, else Optional.empty + */ + Optional<Plan> getPlan(TenantName tenantName); + + /** + * Return the plan for the given tenants if present. + * If the database does not know of the tenant, the tenant is not included in the result. + */ + Map<TenantName, Optional<Plan>> getPlans(List<TenantName> tenants); + + /** + * Set the current plan for the given tenant + * + * @param tenantName The tenant to set the plan for + * @param plan The plan to use + */ + void setPlan(TenantName tenantName, Plan plan); + + /** + * Deactivates the default payment instrument for a tenant, if it exists. + * Used during tenant deletion + */ + void deactivateDefaultPaymentInstrument(TenantName tenant); + + /** + * Get the current collection method for the tenant - if one has persisted + * @return Optional.empty if no collection method has been persisted for the tenant + */ + Optional<CollectionMethod> getCollectionMethod(TenantName tenantName); + + /** + * Set the collection method for the tenant + * @param tenantName The name of the tenant to set collection method for + * @param collectionMethod The collection method for the tenant + */ + void setCollectionMethod(TenantName tenantName, CollectionMethod collectionMethod); +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClientMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClientMock.java new file mode 100644 index 00000000000..f53025a2e6d --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClientMock.java @@ -0,0 +1,178 @@ +// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.api.integration.billing; + +import com.yahoo.config.provision.TenantName; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * @author olaa + */ +public class BillingDatabaseClientMock implements BillingDatabaseClient { + private final Clock clock; + private final PlanRegistry planRegistry; + private final Map<TenantName, Plan> tenantPlans = new HashMap<>(); + private final Map<Bill.Id, TenantName> invoices = new HashMap<>(); + private final Map<Bill.Id, List<Bill.LineItem>> lineItems = new HashMap<>(); + private final Map<TenantName, List<Bill.LineItem>> uncommittedLineItems = new HashMap<>(); + + private final Map<Bill.Id, Bill.StatusHistory> statuses = new HashMap<>(); + private final Map<Bill.Id, ZonedDateTime> startTimes = new HashMap<>(); + private final Map<Bill.Id, ZonedDateTime> endTimes = new HashMap<>(); + + private final ZonedDateTime startTime = LocalDate.of(2020, 4, 1).atStartOfDay(ZoneId.of("UTC")); + private final ZonedDateTime endTime = LocalDate.of(2020, 5, 1).atStartOfDay(ZoneId.of("UTC")); + + private final List<InstrumentOwner> paymentInstruments = new ArrayList<>(); + private final Map<TenantName, CollectionMethod> collectionMethods = new HashMap<>(); + + public BillingDatabaseClientMock(Clock clock, PlanRegistry planRegistry) { + this.clock = clock; + this.planRegistry = planRegistry; + } + + @Override + public boolean setActivePaymentInstrument(InstrumentOwner paymentInstrument) { + return paymentInstruments.add(paymentInstrument); + } + + @Override + public Optional<InstrumentOwner> getDefaultPaymentInstrumentForTenant(TenantName tenantName) { + return paymentInstruments.stream() + .filter(paymentInstrument -> paymentInstrument.getTenantName().equals(tenantName)) + .findFirst(); + } + + public String getStatus(Bill.Id invoiceId) { + return statuses.get(invoiceId).current(); + } + + @Override + public Bill.Id createBill(TenantName tenant, ZonedDateTime startTime, ZonedDateTime endTime, String agent) { + var invoiceId = Bill.Id.generate(); + invoices.put(invoiceId, tenant); + statuses.computeIfAbsent(invoiceId, l -> Bill.StatusHistory.open(clock)); + startTimes.put(invoiceId, startTime); + endTimes.put(invoiceId, endTime); + return invoiceId; + } + + @Override + public Optional<Bill> readBill(Bill.Id billId) { + var invoice = Optional.ofNullable(invoices.get(billId)); + var lines = lineItems.getOrDefault(billId, List.of()); + var status = statuses.getOrDefault(billId, Bill.StatusHistory.open(clock)); + var start = startTimes.getOrDefault(billId, startTime); + var end = endTimes.getOrDefault(billId, endTime); + return invoice.map(tenant -> new Bill(billId, tenant, status, lines, start, end)); + } + + @Override + public String addLineItem(TenantName tenantName, Bill.LineItem lineItem, Optional<Bill.Id> invoiceId) { + var lineItemId = UUID.randomUUID().toString(); + invoiceId.ifPresentOrElse( + invoice -> lineItems.computeIfAbsent(invoice, l -> new ArrayList<>()).add(lineItem), + () -> uncommittedLineItems.computeIfAbsent(tenantName, l -> new ArrayList<>()).add(lineItem) + ); + return lineItemId; + } + + @Override + public void setStatus(Bill.Id invoiceId, String agent, String status) { + statuses.computeIfAbsent(invoiceId, k -> Bill.StatusHistory.open(clock)) + .getHistory() + .put(ZonedDateTime.now(), status); + } + + @Override + public List<Bill.LineItem> getUnusedLineItems(TenantName tenantName) { + return uncommittedLineItems.getOrDefault(tenantName, new ArrayList<>()); + } + + @Override + public void deleteLineItem(String lineItemId) { + uncommittedLineItems.values() + .forEach(list -> + list.removeIf(lineItem -> lineItem.id().equals(lineItemId)) + ); + } + + @Override + public void commitLineItems(TenantName tenantName, Bill.Id invoiceId) { + + } + + @Override + public Optional<Plan> getPlan(TenantName tenantName) { + return Optional.ofNullable(tenantPlans.get(tenantName)); + } + + @Override + public Map<TenantName, Optional<Plan>> getPlans(List<TenantName> tenants) { + return tenantPlans.entrySet().stream() + .filter(entry -> tenants.contains(entry.getKey())) + .collect(Collectors.toMap( + entry -> entry.getKey(), + entry -> planRegistry.plan(entry.getValue().id()) + )); + } + + @Override + public void setPlan(TenantName tenantName, Plan plan) { + tenantPlans.put(tenantName, plan); + } + + @Override + public void deactivateDefaultPaymentInstrument(TenantName tenant) { + paymentInstruments.removeIf(instrumentOwner -> instrumentOwner.getTenantName().equals(tenant)); + } + + @Override + public Optional<CollectionMethod> getCollectionMethod(TenantName tenantName) { + return Optional.ofNullable(collectionMethods.get(tenantName)); + } + + @Override + public void setCollectionMethod(TenantName tenantName, CollectionMethod collectionMethod) { + collectionMethods.put(tenantName, collectionMethod); + } + + @Override + public List<Bill> readBillsForTenant(TenantName tenant) { + return invoices.entrySet().stream() + .filter(entry -> entry.getValue().equals(tenant)) + .map(Map.Entry::getKey) + .map(invoiceId -> { + var items = lineItems.getOrDefault(invoiceId, List.of()); + var status = statuses.get(invoiceId); + var start = startTimes.get(invoiceId); + var end = endTimes.get(invoiceId); + return new Bill(invoiceId, tenant, status, items, start, end); + }) + .collect(Collectors.toList()); + } + + @Override + public List<Bill> readBills() { + return invoices.keySet().stream() + .map(invoiceId -> { + var tenant = invoices.get(invoiceId); + var items = lineItems.getOrDefault(invoiceId, List.of()); + var status = statuses.get(invoiceId); + var start = startTimes.get(invoiceId); + var end = endTimes.get(invoiceId); + return new Bill(invoiceId, tenant, status, items, start, end); + }) + .collect(Collectors.toList()); + } +} 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 8816c4eb57b..f4d3577aeec 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 @@ -26,9 +26,9 @@ 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<>(); - Map<TenantName, Invoice> uncommittedInvoices = new HashMap<>(); - Map<TenantName, List<Invoice.LineItem>> unusedLineItems = new HashMap<>(); + Map<TenantName, List<Bill>> committedBills = new HashMap<>(); + Map<TenantName, Bill> uncommittedBills = new HashMap<>(); + Map<TenantName, List<Bill.LineItem>> unusedLineItems = new HashMap<>(); Map<TenantName, CollectionMethod> collectionMethod = new HashMap<>(); public MockBillingController(Clock clock) { @@ -64,32 +64,32 @@ public class MockBillingController implements BillingController { } @Override - public Invoice.Id createInvoiceForPeriod(TenantName tenant, ZonedDateTime startTime, ZonedDateTime endTime, String agent) { - var invoiceId = Invoice.Id.of("id-123"); - committedInvoices.computeIfAbsent(tenant, l -> new ArrayList<>()) - .add(new Invoice( - invoiceId, + public Bill.Id createBillForPeriod(TenantName tenant, ZonedDateTime startTime, ZonedDateTime endTime, String agent) { + var billId = Bill.Id.of("id-123"); + committedBills.computeIfAbsent(tenant, l -> new ArrayList<>()) + .add(new Bill( + billId, tenant, - Invoice.StatusHistory.open(clock), + Bill.StatusHistory.open(clock), List.of(), startTime, endTime )); - return invoiceId; + return billId; } @Override - public Invoice createUncommittedInvoice(TenantName tenant, LocalDate until) { - return uncommittedInvoices.getOrDefault(tenant, emptyInvoice()); + public Bill createUncommittedBill(TenantName tenant, LocalDate until) { + return uncommittedBills.getOrDefault(tenant, emptyBill()); } @Override - public Map<TenantName, Invoice> createUncommittedInvoices(LocalDate until) { - return uncommittedInvoices; + public Map<TenantName, Bill> createUncommittedBills(LocalDate until) { + return uncommittedBills; } @Override - public List<Invoice.LineItem> getUnusedLineItems(TenantName tenant) { + public List<Bill.LineItem> getUnusedLineItems(TenantName tenant) { return unusedLineItems.getOrDefault(tenant, List.of()); } @@ -110,18 +110,18 @@ public class MockBillingController implements BillingController { } @Override - public void updateInvoiceStatus(Invoice.Id invoiceId, String agent, String status) { + public void updateBillStatus(Bill.Id billId, String agent, String status) { var now = clock.instant().atZone(ZoneOffset.UTC); - committedInvoices.values().stream() + committedBills.values().stream() .flatMap(List::stream) - .filter(invoice -> invoiceId.equals(invoice.id())) - .forEach(invoice -> invoice.statusHistory().history.put(now, status)); + .filter(bill -> billId.equals(bill.id())) + .forEach(bill -> bill.statusHistory().history.put(now, status)); } @Override public void addLineItem(TenantName tenant, String description, BigDecimal amount, String agent) { unusedLineItems.computeIfAbsent(tenant, l -> new ArrayList<>()) - .add(new Invoice.LineItem( + .add(new Bill.LineItem( "line-item-id", description, amount, @@ -152,13 +152,13 @@ public class MockBillingController implements BillingController { } @Override - public List<Invoice> getInvoicesForTenant(TenantName tenant) { - return committedInvoices.getOrDefault(tenant, List.of()); + public List<Bill> getBillsForTenant(TenantName tenant) { + return committedBills.getOrDefault(tenant, List.of()); } @Override - public List<Invoice> getInvoices() { - return committedInvoices.values().stream().flatMap(Collection::stream).collect(Collectors.toList()); + public List<Bill> getBills() { + return committedBills.values().stream().flatMap(Collection::stream).collect(Collectors.toList()); } @Override @@ -191,17 +191,17 @@ public class MockBillingController implements BillingController { "country"); } - public void addInvoice(TenantName tenantName, Invoice invoice, boolean committed) { + public void addBill(TenantName tenantName, Bill bill, boolean committed) { if (committed) - committedInvoices.computeIfAbsent(tenantName, i -> new ArrayList<>()) - .add(invoice); + committedBills.computeIfAbsent(tenantName, i -> new ArrayList<>()) + .add(bill); else - uncommittedInvoices.put(tenantName, invoice); + uncommittedBills.put(tenantName, bill); } - private Invoice emptyInvoice() { + private Bill emptyBill() { 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); + return new Bill(Bill.Id.of("empty"), TenantName.defaultName(), Bill.StatusHistory.open(clock), List.of(), start, end); } } diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistry.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistry.java new file mode 100644 index 00000000000..d64d6e3ea04 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistry.java @@ -0,0 +1,23 @@ +package com.yahoo.vespa.hosted.controller.api.integration.billing; + +import java.util.Optional; + +/** + * Registry of all current plans we have support for + * + * @author ogronnesby + */ +public interface PlanRegistry { + + /** Get the default plan */ + Plan defaultPlan(); + + /** Get a plan given a plan ID */ + Optional<Plan> plan(PlanId planId); + + /** Get a plan give a plan ID */ + default Optional<Plan> plan(String planId) { + return plan(PlanId.from(planId)); + } + +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistryMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistryMock.java new file mode 100644 index 00000000000..60eddbd24ff --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistryMock.java @@ -0,0 +1,122 @@ +package com.yahoo.vespa.hosted.controller.api.integration.billing; + +import com.yahoo.config.provision.NodeResources; +import com.yahoo.vespa.hosted.controller.api.integration.resource.CostInfo; +import com.yahoo.vespa.hosted.controller.api.integration.resource.ResourceUsage; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Optional; +import java.util.stream.Stream; + +public class PlanRegistryMock implements PlanRegistry { + + public static final Plan freeTrial = new MockPlan("trial", false, 0, 0, 0, 200, "Free Trial - for testing purposes"); + public static final Plan paidPlan = new MockPlan("paid", true, "0.09", "0.009", "0.0003", 500, "Paid Plan - for testing purposes"); + public static final Plan nonePlan = new MockPlan("none", false, 0, 0, 0, 0, "None Plan - for testing purposes"); + + @Override + public Plan defaultPlan() { + return freeTrial; + } + + @Override + public Optional<Plan> plan(PlanId planId) { + return Stream.of(freeTrial, paidPlan, nonePlan) + .filter(p -> p.id().equals(planId)) + .findAny(); + } + + private static class MockPlan implements Plan { + private final PlanId planId; + private final String description; + private final CostCalculator costCalculator; + private final QuotaCalculator quotaCalculator; + private final boolean billed; + + public MockPlan(String planId, boolean billed, double cpuPrice, double memPrice, double dgbPrice, int quota, String description) { + this(PlanId.from(planId), billed, new MockCostCalculator(cpuPrice, memPrice, dgbPrice), () -> Quota.unlimited().withBudget(quota), description); + } + + public MockPlan(String planId, boolean billed, String cpuPrice, String memPrice, String dgbPrice, int quota, String description) { + this(PlanId.from(planId), billed, new MockCostCalculator(cpuPrice, memPrice, dgbPrice), () -> Quota.unlimited().withBudget(quota), description); + } + + public MockPlan(PlanId planId, boolean billed, MockCostCalculator calculator, QuotaCalculator quota, String description) { + this.planId = planId; + this.billed = billed; + this.costCalculator = calculator; + this.quotaCalculator = quota; + this.description = description; + } + + @Override + public PlanId id() { + return planId; + } + + @Override + public String displayName() { + return description; + } + + @Override + public CostCalculator calculator() { + return costCalculator; + } + + @Override + public QuotaCalculator quota() { + return quotaCalculator; + } + + @Override + public boolean isBilled() { + return billed; + } + } + + private static class MockCostCalculator implements CostCalculator { + private static final BigDecimal millisPerHour = BigDecimal.valueOf(60 * 60 * 1000); + private final BigDecimal cpuHourCost; + private final BigDecimal memHourCost; + private final BigDecimal dgbHourCost; + + public MockCostCalculator(String cpuPrice, String memPrice, String dgbPrice) { + this(new BigDecimal(cpuPrice), new BigDecimal(memPrice), new BigDecimal(dgbPrice)); + } + + public MockCostCalculator(double cpuPrice, double memPrice, double dgbPrice) { + this(BigDecimal.valueOf(cpuPrice), BigDecimal.valueOf(memPrice), BigDecimal.valueOf(dgbPrice)); + } + + public MockCostCalculator(BigDecimal cpuPrice, BigDecimal memPrice, BigDecimal dgbPrice) { + this.cpuHourCost = cpuPrice; + this.memHourCost = memPrice; + this.dgbHourCost = dgbPrice; + } + + @Override + public CostInfo calculate(ResourceUsage usage) { + var cpuCost = usage.getCpuMillis().multiply(cpuHourCost).divide(millisPerHour, RoundingMode.HALF_UP).setScale(2, RoundingMode.HALF_UP); + var memCost = usage.getMemoryMillis().multiply(memHourCost).divide(millisPerHour, RoundingMode.HALF_UP).setScale(2, RoundingMode.HALF_UP); + var dgbCost = usage.getDiskMillis().multiply(dgbHourCost).divide(millisPerHour, RoundingMode.HALF_UP).setScale(2, RoundingMode.HALF_UP); + + return new CostInfo( + usage.getApplicationId(), + usage.getZoneId(), + usage.getCpuMillis().divide(millisPerHour, RoundingMode.HALF_UP), + usage.getMemoryMillis().divide(millisPerHour, RoundingMode.HALF_UP), + usage.getDiskMillis().divide(millisPerHour, RoundingMode.HALF_UP), + cpuCost, + memCost, + dgbCost + ); + } + + @Override + public double calculate(NodeResources resources) { + return resources.cost(); + } + } +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceDatabaseClient.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceDatabaseClient.java new file mode 100644 index 00000000000..2f277193231 --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceDatabaseClient.java @@ -0,0 +1,51 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.api.integration.resource; + +import com.yahoo.config.provision.ApplicationName; +import com.yahoo.config.provision.TenantName; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +/** + * @author olaa + */ +public interface ResourceDatabaseClient { + + void writeResourceSnapshots(Collection<ResourceSnapshot> snapshots); + + List<ResourceSnapshot> getResourceSnapshotsForMonth(TenantName tenantName, ApplicationName applicationName, YearMonth month); + + List<ResourceUsage> getResourceSnapshotsForPeriod(TenantName tenantName, long startTimestamp, long endTimestamp); + + void refreshMaterializedView(); + + Set<YearMonth> getMonthsWithSnapshotsForTenant(TenantName tenantName); + + List<ResourceSnapshot> getRawSnapshotHistoryForTenant(TenantName tenantName, YearMonth yearMonth); + + Set<TenantName> getTenants(); + + default List<ResourceUsage> getResourceSnapshotsForMonth(TenantName tenantName, YearMonth month) { + return getResourceSnapshotsForPeriod(tenantName, getMonthStartTimeStamp(month), getMonthEndTimeStamp(month)); + } + + private long getMonthStartTimeStamp(YearMonth month) { + LocalDate startOfMonth = LocalDate.of(month.getYear(), month.getMonth(), 1); + return startOfMonth.atStartOfDay(java.time.ZoneId.of("UTC")) + .toInstant() + .toEpochMilli(); + } + private long getMonthEndTimeStamp(YearMonth month) { + LocalDate startOfMonth = LocalDate.of(month.getYear(), month.getMonth(), 1); + return startOfMonth.plus(1, ChronoUnit.MONTHS) + .atStartOfDay(java.time.ZoneId.of("UTC")) + .toInstant() + .toEpochMilli(); + } + +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceDatabaseClientMock.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceDatabaseClientMock.java new file mode 100644 index 00000000000..5a4d250ea9d --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceDatabaseClientMock.java @@ -0,0 +1,146 @@ +// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.api.integration.resource; + +import com.yahoo.config.provision.ApplicationName; +import com.yahoo.config.provision.TenantName; +import com.yahoo.vespa.hosted.controller.api.integration.billing.Plan; +import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanRegistry; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * @author olaa + */ +public class ResourceDatabaseClientMock implements ResourceDatabaseClient { + + PlanRegistry planRegistry; + Map<TenantName, Plan> planMap = new HashMap<>(); + List<ResourceSnapshot> resourceSnapshots = new ArrayList<>(); + private boolean hasRefreshedMaterializedView = false; + + public ResourceDatabaseClientMock(PlanRegistry planRegistry) { + this.planRegistry = planRegistry; + } + + @Override + public void writeResourceSnapshots(Collection<ResourceSnapshot> items) { + this.resourceSnapshots.addAll(items); + } + + @Override + public List<ResourceSnapshot> getResourceSnapshotsForMonth(TenantName tenantName, ApplicationName applicationName, YearMonth month) { + return resourceSnapshots.stream() + .filter(resourceSnapshot -> { + LocalDate snapshotDate = LocalDate.ofInstant(resourceSnapshot.getTimestamp(), ZoneId.of("UTC")); + return YearMonth.from(snapshotDate).equals(month) && + snapshotDate.getYear() == month.getYear() && + resourceSnapshot.getApplicationId().tenant().equals(tenantName) && + resourceSnapshot.getApplicationId().application().equals(applicationName); + }) + .collect(Collectors.toList()); + } + + @Override + public Set<YearMonth> getMonthsWithSnapshotsForTenant(TenantName tenantName) { + return Collections.emptySet(); + } + + @Override + public List<ResourceSnapshot> getRawSnapshotHistoryForTenant(TenantName tenantName, YearMonth yearMonth) { + return resourceSnapshots; + } + + @Override + public Set<TenantName> getTenants() { + return resourceSnapshots.stream() + .map(snapshot -> snapshot.getApplicationId().tenant()) + .collect(Collectors.toSet()); + } + + private List<ResourceUsage> resourceUsageFromSnapshots(Plan plan, List<ResourceSnapshot> snapshots) { + snapshots.sort(Comparator.comparing(ResourceSnapshot::getTimestamp)); + + return IntStream.range(0, snapshots.size()) + .mapToObj(idx -> { + var a = snapshots.get(idx); + var b = (idx + 1) < snapshots.size() ? snapshots.get(idx + 1) : null; + var start = a.getTimestamp(); + var end = Optional.ofNullable(b).map(ResourceSnapshot::getTimestamp).orElse(start.plusSeconds(120)); + var d = BigDecimal.valueOf(Duration.between(start, end).toMillis()); + return new ResourceUsage( + a.getApplicationId(), + a.getZoneId(), + plan, + BigDecimal.valueOf(a.getCpuCores()).multiply(d), + BigDecimal.valueOf(a.getMemoryGb()).multiply(d), + BigDecimal.valueOf(a.getDiskGb()).multiply(d) + ); + }) + .collect(Collectors.toList()); + } + + private ResourceUsage resourceUsageAdd(ResourceUsage a, ResourceUsage b) { + assert a.getApplicationId().equals(b.getApplicationId()); + assert a.getZoneId().equals(b.getZoneId()); + assert a.getPlan().equals(b.getPlan()); + return new ResourceUsage( + a.getApplicationId(), + a.getZoneId(), + a.getPlan(), + a.getCpuMillis().add(b.getCpuMillis()), + a.getMemoryMillis().add(b.getMemoryMillis()), + a.getDiskMillis().add(b.getDiskMillis()) + ); + } + + @Override + public List<ResourceUsage> getResourceSnapshotsForPeriod(TenantName tenantName, long start, long end) { + var tenantPlan = planMap.getOrDefault(tenantName, planRegistry.defaultPlan()); + + var snapshotsPerDeployment = resourceSnapshots.stream() + .filter(snapshot -> snapshot.getTimestamp().isAfter(Instant.ofEpochMilli(start))) + .filter(snapshot -> snapshot.getTimestamp().isBefore(Instant.ofEpochMilli(end))) + .filter(snapshot -> snapshot.getApplicationId().tenant().equals(tenantName)) + .collect(Collectors.groupingBy( + usage -> Objects.hash(usage.getApplicationId(), usage.getZoneId(), tenantPlan.id().value()) + )) + .values().stream() + .map(snapshots -> resourceUsageFromSnapshots(tenantPlan, snapshots)) + .map(usages -> usages.stream().reduce(this::resourceUsageAdd)) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + + return snapshotsPerDeployment; + } + + @Override + public void refreshMaterializedView() { + hasRefreshedMaterializedView = true; + } + + public void setPlan(TenantName tenant, Plan plan) { + planMap.put(tenant, plan); + } + + public boolean hasRefreshedMaterializedView() { + return hasRefreshedMaterializedView; + } +} diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceSnapshot.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceSnapshot.java index a8d9c4b1f8a..319b9239ae4 100644 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceSnapshot.java +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceSnapshot.java @@ -31,6 +31,10 @@ public class ResourceSnapshot { this.zoneId = zoneId; } + public static ResourceSnapshot from(ApplicationId applicationId, int nodes, double cpuCores, double memoryGb, double diskGb, Instant timestamp, ZoneId zoneId) { + return new ResourceSnapshot(applicationId, cpuCores * nodes, memoryGb * nodes, diskGb * nodes, timestamp, zoneId); + } + public static ResourceSnapshot from(List<Node> nodes, Instant timestamp, ZoneId zoneId) { Set<ApplicationId> applicationIds = nodes.stream() .filter(node -> node.owner().isPresent()) |