From 5e9f649da988f171e34952f6626096dbd4c2093b Mon Sep 17 00:00:00 2001 From: Øyvind Grønnesby Date: Mon, 11 Oct 2021 09:13:12 +0200 Subject: Revert "Revert "Billing refactoring and cleanup"" --- .../api/integration/ServiceRegistry.java | 9 + .../controller/api/integration/billing/Bill.java | 347 +++++++++++++++++++++ .../api/integration/billing/BillingController.java | 71 ++++- .../integration/billing/BillingDatabaseClient.java | 135 ++++++++ .../billing/BillingDatabaseClientMock.java | 178 +++++++++++ .../api/integration/billing/Invoice.java | 347 --------------------- .../integration/billing/MockBillingController.java | 60 ++-- .../api/integration/billing/PlanRegistry.java | 23 ++ .../api/integration/billing/PlanRegistryMock.java | 122 ++++++++ .../resource/ResourceDatabaseClient.java | 51 +++ .../resource/ResourceDatabaseClientMock.java | 146 +++++++++ .../api/integration/resource/ResourceSnapshot.java | 4 + 12 files changed, 1109 insertions(+), 384 deletions(-) create mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Bill.java create mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClient.java create mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/BillingDatabaseClientMock.java delete mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java create mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistry.java create mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/PlanRegistryMock.java create mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceDatabaseClient.java create mode 100644 controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/resource/ResourceDatabaseClientMock.java (limited to 'controller-api') 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/Bill.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Bill.java new file mode 100644 index 00000000000..d1af5b428de --- /dev/null +++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Bill.java @@ -0,0 +1,347 @@ +// Copyright Yahoo. 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.ApplicationId; +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; +import java.util.Objects; +import java.util.Optional; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.UUID; +import java.util.function.Function; + + +/** + * 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. + *

+ * All line items have a Plan associated with them - which was used to map from utilization to an actual price. + *

+ * The bill has a status history, but only the latest status is exposed through this API. + * + * @author ogronnesby + */ +public class Bill { + private static final BigDecimal SCALED_ZERO = new BigDecimal("0.00"); + + private final Id id; + private final TenantName tenant; + private final List lineItems; + private final StatusHistory statusHistory; + private final ZonedDateTime startTime; + private final ZonedDateTime endTime; + + public Bill(Id id, TenantName tenant, StatusHistory statusHistory, List lineItems, ZonedDateTime startTime, ZonedDateTime endTime) { + this.id = id; + this.tenant = tenant; + this.lineItems = List.copyOf(lineItems); + this.statusHistory = statusHistory; + this.startTime = startTime; + this.endTime = endTime; + } + + public Id id() { + return id; + } + + public TenantName tenant() { + return tenant; + } + + public String status() { + return statusHistory.current(); + } + + public StatusHistory statusHistory() { + return statusHistory; + } + + public List lineItems() { + return lineItems; + } + + public ZonedDateTime getStartTime() { + return startTime; + } + + public ZonedDateTime getEndTime() { + return endTime; + } + + public BigDecimal sum() { + return lineItems.stream().map(LineItem::amount).reduce(SCALED_ZERO, BigDecimal::add); + } + + public BigDecimal sumCpuHours() { + return sumResourceValues(LineItem::getCpuHours); + } + + public BigDecimal sumMemoryHours() { + return sumResourceValues(LineItem::getMemoryHours); + } + + public BigDecimal sumDiskHours() { + return sumResourceValues(LineItem::getDiskHours); + } + + public BigDecimal sumCpuCost() { + return sumResourceValues(LineItem::getCpuCost); + } + + public BigDecimal sumMemoryCost() { + return sumResourceValues(LineItem::getMemoryCost); + } + + public BigDecimal sumDiskCost() { + return sumResourceValues(LineItem::getDiskCost); + } + + public BigDecimal sumAdditionalCost() { + // anything that is not covered by the cost for resources is "additional" costs + var resourceCosts = sumCpuCost().add(sumMemoryCost()).add(sumDiskCost()); + return sum().subtract(resourceCosts); + } + + private BigDecimal sumResourceValues(Function> f) { + return lineItems.stream().flatMap(li -> f.apply(li).stream()).reduce(SCALED_ZERO, BigDecimal::add); + } + + public static final class Id { + private final String value; + + public static Id of(String value) { + Objects.requireNonNull(value); + return new Id(value); + } + + public static Id generate() { + var id = UUID.randomUUID().toString(); + return new Id(id); + } + + private Id(String value) { + this.value = value; + } + + public String value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Id billId = (Id) o; + return value.equals(billId.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "BillId{" + + "value='" + value + '\'' + + '}'; + } + } + + /** + * Represents a chargeable line on a bill. + */ + public static class LineItem { + private final String id; + private final String description; + private final BigDecimal amount; + private final String plan; + private final String agent; + private final ZonedDateTime addedAt; + private ZonedDateTime startedAt; + private ZonedDateTime endedAt; + private ApplicationId applicationId; + private ZoneId zoneId; + private BigDecimal cpuHours; + private BigDecimal memoryHours; + private BigDecimal diskHours; + private BigDecimal cpuCost; + private BigDecimal memoryCost; + private BigDecimal diskCost; + + public LineItem(String id, String description, BigDecimal amount, String plan, String agent, ZonedDateTime addedAt) { + this.id = id; + this.description = description; + this.amount = amount; + this.plan = plan; + this.agent = agent; + this.addedAt = addedAt; + } + + public LineItem(String id, String description, BigDecimal amount, String plan, String agent, ZonedDateTime addedAt, ZonedDateTime startedAt, ZonedDateTime endedAt, ApplicationId applicationId, ZoneId zoneId, + BigDecimal cpuHours, BigDecimal memoryHours, BigDecimal diskHours, BigDecimal cpuCost, BigDecimal memoryCost, BigDecimal diskCost) { + this(id, description, amount, plan, agent, addedAt); + this.startedAt = startedAt; + this.endedAt = endedAt; + + if (applicationId == null && zoneId != null) + throw new IllegalArgumentException("Must supply applicationId if zoneId is supplied"); + + this.applicationId = applicationId; + this.zoneId = zoneId; + this.cpuHours = cpuHours; + this.memoryHours = memoryHours; + this.diskHours = diskHours; + this.cpuCost = cpuCost; + this.memoryCost = memoryCost; + this.diskCost = diskCost; + } + + /** The opaque ID of this */ + public String id() { + return id; + } + + /** The string description of this - used for display purposes */ + public String description() { + return description; + } + + /** The dollar amount of this */ + public BigDecimal amount() { + return SCALED_ZERO.add(amount); + } + + /** The plan used to calculate amount of this */ + public String plan() { + return plan; + } + + /** Who created this line item */ + public String agent() { + return agent; + } + + /** When was this line item added */ + public ZonedDateTime addedAt() { + return addedAt; + } + + /** What time period is this line item for - time start */ + public Optional startedAt() { + return Optional.ofNullable(startedAt); + } + + /** What time period is this line item for - time end */ + public Optional endedAt() { + return Optional.ofNullable(endedAt); + } + + /** Optionally - what application is this line item about */ + public Optional applicationId() { + return Optional.ofNullable(applicationId); + } + + /** Optionally - what zone deployment is this line item about */ + public Optional zoneId() { + return Optional.ofNullable(zoneId); + } + + public Optional getCpuHours() { + return Optional.ofNullable(cpuHours); + } + + public Optional getMemoryHours() { + return Optional.ofNullable(memoryHours); + } + + public Optional getDiskHours() { + return Optional.ofNullable(diskHours); + } + + public Optional getCpuCost() { + return Optional.ofNullable(cpuCost); + } + + public Optional getMemoryCost() { + return Optional.ofNullable(memoryCost); + } + + public Optional getDiskCost() { + return Optional.ofNullable(diskCost); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + LineItem lineItem = (LineItem) o; + return id.equals(lineItem.id) && + description.equals(lineItem.description) && + amount.equals(lineItem.amount) && + plan.equals(lineItem.plan) && + agent.equals(lineItem.agent) && + addedAt.equals(lineItem.addedAt) && + startedAt.equals(lineItem.startedAt) && + endedAt.equals(lineItem.endedAt) && + applicationId.equals(lineItem.applicationId) && + zoneId.equals(lineItem.zoneId); + } + + @Override + public int hashCode() { + return Objects.hash(id, description, amount, plan, agent, addedAt, startedAt, endedAt, applicationId, zoneId); + } + + @Override + public String toString() { + return "LineItem{" + + "id='" + id + '\'' + + ", description='" + description + '\'' + + ", amount=" + amount + + ", plan='" + plan + '\'' + + ", agent='" + agent + '\'' + + ", addedAt=" + addedAt + + ", startedAt=" + startedAt + + ", endedAt=" + endedAt + + ", applicationId=" + applicationId + + ", zoneId=" + zoneId + + '}'; + } + } + + public static class StatusHistory { + SortedMap history; + + public StatusHistory(SortedMap history) { + this.history = history; + } + + public static StatusHistory open(Clock clock) { + var now = clock.instant().atZone(ZoneOffset.UTC); + return new StatusHistory( + new TreeMap<>(Map.of(now, "OPEN")) + ); + } + + public String current() { + return history.get(history.lastKey()); + } + + public SortedMap getHistory() { + return history; + } + + } + +} 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 tenantsWithPlan(List 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 createUncommittedInvoices(LocalDate until); + /** Run {createUncommittedBill} for all tenants with unbilled use */ + Map createUncommittedBills(LocalDate until); - List getUnusedLineItems(TenantName tenant); + /** Get line items that have been manually added to a tenant, but is not yet part of a bill */ + List getUnusedLineItems(TenantName tenant); + /** Get the payment instrument for the given tenant */ Optional 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 getInvoicesForTenant(TenantName tenant); + /** Get all bills for the given tenant */ + List getBillsForTenant(TenantName tenant); - List getInvoices(); + /** Get all bills from the system */ + List getBills(); + /** Delete billing contact information from the tenant */ void deleteBillingInfo(TenantName tenant, Set 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 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 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 readBillsForTenant(TenantName tenant); + + /** + * Read all bills, ordered by date + * @return List of all bills ordered by date + */ + List 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 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 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 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> getPlans(List 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 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 tenantPlans = new HashMap<>(); + private final Map invoices = new HashMap<>(); + private final Map> lineItems = new HashMap<>(); + private final Map> uncommittedLineItems = new HashMap<>(); + + private final Map statuses = new HashMap<>(); + private final Map startTimes = new HashMap<>(); + private final Map 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 paymentInstruments = new ArrayList<>(); + private final Map 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 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 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 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 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 getPlan(TenantName tenantName) { + return Optional.ofNullable(tenantPlans.get(tenantName)); + } + + @Override + public Map> getPlans(List 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 getCollectionMethod(TenantName tenantName) { + return Optional.ofNullable(collectionMethods.get(tenantName)); + } + + @Override + public void setCollectionMethod(TenantName tenantName, CollectionMethod collectionMethod) { + collectionMethods.put(tenantName, collectionMethod); + } + + @Override + public List 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 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/Invoice.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java deleted file mode 100644 index 3789021ae8e..00000000000 --- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/billing/Invoice.java +++ /dev/null @@ -1,347 +0,0 @@ -// Copyright Yahoo. 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.ApplicationId; -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; -import java.util.Objects; -import java.util.Optional; -import java.util.SortedMap; -import java.util.TreeMap; -import java.util.UUID; -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 - * a given deployment in Vespa Cloud, but they can also be manually added to e.g. give a discount or represent - * support. - *

- * All line items have a Plan associated with them - which was used to map from utilization to an actual price. - *

- * The invoice has a status history, but only the latest status is exposed through this API. - * - * @author ogronnesby - */ -public class Invoice { - private static final BigDecimal SCALED_ZERO = new BigDecimal("0.00"); - - private final Id id; - private final TenantName tenant; - private final List lineItems; - private final StatusHistory statusHistory; - private final ZonedDateTime startTime; - private final ZonedDateTime endTime; - - public Invoice(Id id, TenantName tenant, StatusHistory statusHistory, List lineItems, ZonedDateTime startTime, ZonedDateTime endTime) { - this.id = id; - this.tenant = tenant; - this.lineItems = List.copyOf(lineItems); - this.statusHistory = statusHistory; - this.startTime = startTime; - this.endTime = endTime; - } - - public Id id() { - return id; - } - - public TenantName tenant() { - return tenant; - } - - public String status() { - return statusHistory.current(); - } - - public StatusHistory statusHistory() { - return statusHistory; - } - - public List lineItems() { - return lineItems; - } - - public ZonedDateTime getStartTime() { - return startTime; - } - - public ZonedDateTime getEndTime() { - return endTime; - } - - public BigDecimal sum() { - return lineItems.stream().map(LineItem::amount).reduce(SCALED_ZERO, BigDecimal::add); - } - - public BigDecimal sumCpuHours() { - return sumResourceValues(LineItem::getCpuHours); - } - - public BigDecimal sumMemoryHours() { - return sumResourceValues(LineItem::getMemoryHours); - } - - public BigDecimal sumDiskHours() { - return sumResourceValues(LineItem::getDiskHours); - } - - public BigDecimal sumCpuCost() { - return sumResourceValues(LineItem::getCpuCost); - } - - public BigDecimal sumMemoryCost() { - return sumResourceValues(LineItem::getMemoryCost); - } - - public BigDecimal sumDiskCost() { - return sumResourceValues(LineItem::getDiskCost); - } - - public BigDecimal sumAdditionalCost() { - // anything that is not covered by the cost for resources is "additional" costs - var resourceCosts = sumCpuCost().add(sumMemoryCost()).add(sumDiskCost()); - return sum().subtract(resourceCosts); - } - - private BigDecimal sumResourceValues(Function> f) { - return lineItems.stream().flatMap(li -> f.apply(li).stream()).reduce(SCALED_ZERO, BigDecimal::add); - } - - public static final class Id { - private final String value; - - public static Id of(String value) { - Objects.requireNonNull(value); - return new Id(value); - } - - public static Id generate() { - var id = UUID.randomUUID().toString(); - return new Id(id); - } - - private Id(String value) { - this.value = value; - } - - public String value() { - return value; - } - - @Override - 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); - } - - @Override - public int hashCode() { - return Objects.hash(value); - } - - @Override - public String toString() { - return "InvoiceId{" + - "value='" + value + '\'' + - '}'; - } - } - - /** - * Represents a chargeable line on an invoice. - */ - public static class LineItem { - private final String id; - private final String description; - private final BigDecimal amount; - private final String plan; - private final String agent; - private final ZonedDateTime addedAt; - private ZonedDateTime startedAt; - private ZonedDateTime endedAt; - private ApplicationId applicationId; - private ZoneId zoneId; - private BigDecimal cpuHours; - private BigDecimal memoryHours; - private BigDecimal diskHours; - private BigDecimal cpuCost; - private BigDecimal memoryCost; - private BigDecimal diskCost; - - public LineItem(String id, String description, BigDecimal amount, String plan, String agent, ZonedDateTime addedAt) { - this.id = id; - this.description = description; - this.amount = amount; - this.plan = plan; - this.agent = agent; - this.addedAt = addedAt; - } - - public LineItem(String id, String description, BigDecimal amount, String plan, String agent, ZonedDateTime addedAt, ZonedDateTime startedAt, ZonedDateTime endedAt, ApplicationId applicationId, ZoneId zoneId, - BigDecimal cpuHours, BigDecimal memoryHours, BigDecimal diskHours, BigDecimal cpuCost, BigDecimal memoryCost, BigDecimal diskCost) { - this(id, description, amount, plan, agent, addedAt); - this.startedAt = startedAt; - this.endedAt = endedAt; - - if (applicationId == null && zoneId != null) - throw new IllegalArgumentException("Must supply applicationId if zoneId is supplied"); - - this.applicationId = applicationId; - this.zoneId = zoneId; - this.cpuHours = cpuHours; - this.memoryHours = memoryHours; - this.diskHours = diskHours; - this.cpuCost = cpuCost; - this.memoryCost = memoryCost; - this.diskCost = diskCost; - } - - /** The opaque ID of this */ - public String id() { - return id; - } - - /** The string description of this - used for display purposes */ - public String description() { - return description; - } - - /** The dollar amount of this */ - public BigDecimal amount() { - return SCALED_ZERO.add(amount); - } - - /** The plan used to calculate amount of this */ - public String plan() { - return plan; - } - - /** Who created this line item */ - public String agent() { - return agent; - } - - /** When was this line item added */ - public ZonedDateTime addedAt() { - return addedAt; - } - - /** What time period is this line item for - time start */ - public Optional startedAt() { - return Optional.ofNullable(startedAt); - } - - /** What time period is this line item for - time end */ - public Optional endedAt() { - return Optional.ofNullable(endedAt); - } - - /** Optionally - what application is this line item about */ - public Optional applicationId() { - return Optional.ofNullable(applicationId); - } - - /** Optionally - what zone deployment is this line item about */ - public Optional zoneId() { - return Optional.ofNullable(zoneId); - } - - public Optional getCpuHours() { - return Optional.ofNullable(cpuHours); - } - - public Optional getMemoryHours() { - return Optional.ofNullable(memoryHours); - } - - public Optional getDiskHours() { - return Optional.ofNullable(diskHours); - } - - public Optional getCpuCost() { - return Optional.ofNullable(cpuCost); - } - - public Optional getMemoryCost() { - return Optional.ofNullable(memoryCost); - } - - public Optional getDiskCost() { - return Optional.ofNullable(diskCost); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - LineItem lineItem = (LineItem) o; - return id.equals(lineItem.id) && - description.equals(lineItem.description) && - amount.equals(lineItem.amount) && - plan.equals(lineItem.plan) && - agent.equals(lineItem.agent) && - addedAt.equals(lineItem.addedAt) && - startedAt.equals(lineItem.startedAt) && - endedAt.equals(lineItem.endedAt) && - applicationId.equals(lineItem.applicationId) && - zoneId.equals(lineItem.zoneId); - } - - @Override - public int hashCode() { - return Objects.hash(id, description, amount, plan, agent, addedAt, startedAt, endedAt, applicationId, zoneId); - } - - @Override - public String toString() { - return "LineItem{" + - "id='" + id + '\'' + - ", description='" + description + '\'' + - ", amount=" + amount + - ", plan='" + plan + '\'' + - ", agent='" + agent + '\'' + - ", addedAt=" + addedAt + - ", startedAt=" + startedAt + - ", endedAt=" + endedAt + - ", applicationId=" + applicationId + - ", zoneId=" + zoneId + - '}'; - } - } - - public static class StatusHistory { - SortedMap history; - - public StatusHistory(SortedMap history) { - this.history = history; - } - - public static StatusHistory open(Clock clock) { - var now = clock.instant().atZone(ZoneOffset.UTC); - return new StatusHistory( - new TreeMap<>(Map.of(now, "OPEN")) - ); - } - - public String current() { - return history.get(history.lastKey()); - } - - public SortedMap getHistory() { - return history; - } - - } - -} 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 plans = new HashMap<>(); Map activeInstruments = new HashMap<>(); - Map> committedInvoices = new HashMap<>(); - Map uncommittedInvoices = new HashMap<>(); - Map> unusedLineItems = new HashMap<>(); + Map> committedBills = new HashMap<>(); + Map uncommittedBills = new HashMap<>(); + Map> unusedLineItems = new HashMap<>(); Map 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 createUncommittedInvoices(LocalDate until) { - return uncommittedInvoices; + public Map createUncommittedBills(LocalDate until) { + return uncommittedBills; } @Override - public List getUnusedLineItems(TenantName tenant) { + public List 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 getInvoicesForTenant(TenantName tenant) { - return committedInvoices.getOrDefault(tenant, List.of()); + public List getBillsForTenant(TenantName tenant) { + return committedBills.getOrDefault(tenant, List.of()); } @Override - public List getInvoices() { - return committedInvoices.values().stream().flatMap(Collection::stream).collect(Collectors.toList()); + public List 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(PlanId planId); + + /** Get a plan give a plan ID */ + default Optional 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(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 snapshots); + + List getResourceSnapshotsForMonth(TenantName tenantName, ApplicationName applicationName, YearMonth month); + + List getResourceSnapshotsForPeriod(TenantName tenantName, long startTimestamp, long endTimestamp); + + void refreshMaterializedView(); + + Set getMonthsWithSnapshotsForTenant(TenantName tenantName); + + List getRawSnapshotHistoryForTenant(TenantName tenantName, YearMonth yearMonth); + + Set getTenants(); + + default List 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 planMap = new HashMap<>(); + List resourceSnapshots = new ArrayList<>(); + private boolean hasRefreshedMaterializedView = false; + + public ResourceDatabaseClientMock(PlanRegistry planRegistry) { + this.planRegistry = planRegistry; + } + + @Override + public void writeResourceSnapshots(Collection items) { + this.resourceSnapshots.addAll(items); + } + + @Override + public List 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 getMonthsWithSnapshotsForTenant(TenantName tenantName) { + return Collections.emptySet(); + } + + @Override + public List getRawSnapshotHistoryForTenant(TenantName tenantName, YearMonth yearMonth) { + return resourceSnapshots; + } + + @Override + public Set getTenants() { + return resourceSnapshots.stream() + .map(snapshot -> snapshot.getApplicationId().tenant()) + .collect(Collectors.toSet()); + } + + private List resourceUsageFromSnapshots(Plan plan, List 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 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 nodes, Instant timestamp, ZoneId zoneId) { Set applicationIds = nodes.stream() .filter(node -> node.owner().isPresent()) -- cgit v1.2.3