blob: 11b45edc5f0686b5a220dfe94a97b09bc3ceef54 [file] [log] [blame]
/*
* Copyright (c) 2017, EPAM SYSTEMS INC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.epam.dlab.backendapi.dao;
import com.epam.dlab.MongoKeyWords;
import com.epam.dlab.auth.UserInfo;
import com.epam.dlab.backendapi.resources.dto.BillingFilter;
import com.epam.dlab.backendapi.roles.RoleType;
import com.epam.dlab.backendapi.roles.UserRoles;
import com.epam.dlab.billing.BillingCalculationUtils;
import com.epam.dlab.billing.DlabResourceType;
import com.epam.dlab.dto.UserInstanceStatus;
import com.epam.dlab.dto.base.DataEngineType;
import com.epam.dlab.model.aws.ReportLine;
import com.google.common.collect.Lists;
import com.google.inject.Inject;
import com.mongodb.client.AggregateIterable;
import com.mongodb.client.FindIterable;
import com.mongodb.client.model.Aggregates;
import com.mongodb.client.model.Filters;
import lombok.Getter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.bson.Document;
import org.bson.conversions.Bson;
import java.math.BigDecimal;
import java.util.*;
import java.util.function.Supplier;
import static com.epam.dlab.backendapi.dao.ComputationalDAO.COMPUTATIONAL_ID;
import static com.epam.dlab.backendapi.dao.ExploratoryDAO.COMPUTATIONAL_RESOURCES;
import static com.epam.dlab.backendapi.dao.ExploratoryDAO.EXPLORATORY_ID;
import static com.epam.dlab.backendapi.dao.MongoCollections.*;
import static com.epam.dlab.model.aws.ReportLine.FIELD_RESOURCE_TYPE;
import static com.epam.dlab.model.aws.ReportLine.FIELD_USAGE_DATE;
import static com.mongodb.client.model.Accumulators.sum;
import static com.mongodb.client.model.Aggregates.group;
import static com.mongodb.client.model.Aggregates.match;
import static com.mongodb.client.model.Filters.*;
import static com.mongodb.client.model.Projections.*;
import static java.util.Collections.singletonList;
@Slf4j
public abstract class BaseBillingDAO<T extends BillingFilter> extends BaseDAO implements BillingDAO<T> {
public static final String SHAPE = "shape";
public static final String SERVICE_BASE_NAME = "service_base_name";
public static final String ITEMS = "lines";
public static final String COST_TOTAL = "cost_total";
public static final String FULL_REPORT = "full_report";
private static final String MASTER_NODE_SHAPE = "master_node_shape";
private static final String SLAVE_NODE_SHAPE = "slave_node_shape";
private static final String TOTAL_INSTANCE_NUMBER = "total_instance_number";
private static final String DATAENGINE_SHAPE = "dataengine_instance_shape";
private static final String DATAENGINE_INSTANCE_COUNT = "dataengine_instance_count";
private static final String DATAENGINE_DOCKER_IMAGE = "image";
private static final int ONE_HUNDRED = 100;
private static final String TOTAL_FIELD_NAME = "total";
private static final String COST_FIELD = "$cost";
public static final String SHARED_RESOURCE_NAME = "Shared resource";
@Inject
protected SettingsDAO settings;
@Inject
private UserSettingsDAO userSettingsDAO;
@Override
public Document getReport(UserInfo userInfo, T filter) {
boolean isFullReport = UserRoles.checkAccess(userInfo, RoleType.PAGE, "/api/infrastructure_provision/billing");
setUserFilter(userInfo, filter, isFullReport);
List<Bson> matchCriteria = matchCriteria(filter);
List<Bson> pipeline = new ArrayList<>();
if (!matchCriteria.isEmpty()) {
pipeline.add(Aggregates.match(Filters.and(matchCriteria)));
}
pipeline.add(groupCriteria());
pipeline.add(sortCriteria());
final Map<String, ShapeInfo> shapes = getShapes(filter.getShapes());
return prepareReport(filter.getStatuses(), !filter.getShapes().isEmpty(), getCollection(BILLING).aggregate(pipeline), shapes, isFullReport); //TODO add shapes
}
private Document prepareReport(List<UserInstanceStatus> statuses, boolean filterByShape,
AggregateIterable<Document> agg,
Map<String, ShapeInfo> shapes, boolean fullReport) {
List<Document> reportItems = new ArrayList<>();
Date usageDateStart = null;
Date usageDateEnd = null;
double costTotal = 0D;
for (Document d : agg) {
Document id = (Document) d.get(MongoKeyWords.MONGO_ID);
String resourceId = id.getString(dlabIdFieldName());
ShapeInfo shape = shapes.get(resourceId);
final UserInstanceStatus status = Optional.ofNullable(shape).map(ShapeInfo::getStatus).orElse(null);
if ((filterByShape && shape == null) ||
(!statuses.isEmpty() && statuses.stream().noneMatch(s -> s.equals(status)))) {
continue;
}
final Date dateStart = d.getDate(usageDateFromFieldName());
if (usageDateStart == null || dateStart.before(usageDateStart)) {
usageDateStart = dateStart;
}
Date dateEnd = d.getDate(usageDateToFieldName());
if (usageDateEnd == null || dateEnd.after(usageDateEnd)) {
usageDateEnd = dateEnd;
}
costTotal += d.getDouble(MongoKeyWords.COST);
final String statusString = Optional
.ofNullable(status)
.map(UserInstanceStatus::toString)
.orElse(StringUtils.EMPTY);
Document item = new Document()
.append(MongoKeyWords.DLAB_USER, getUserOrDefault(id.getString(USER)))
.append(dlabIdFieldName(), resourceId)
.append(shapeFieldName(), generateShapeName(shape))
.append(FIELD_RESOURCE_TYPE, DlabResourceType.getResourceTypeName(id.getString("dlab_resource_type"))) //todo check on azure!!!
.append(STATUS,
statusString)
.append(productFieldName(), id.getString(productFieldName()))
.append(MongoKeyWords.COST, d.getDouble(MongoKeyWords.COST))
.append(costFieldName(), BillingCalculationUtils.formatDouble(d.getDouble(MongoKeyWords
.COST)))
.append(currencyCodeFieldName(), id.getString(currencyCodeFieldName()))
.append(usageDateFromFieldName(), dateStart)
.append(usageDateToFieldName(), dateEnd);
reportItems.add(item);
}
return new Document()
.append(SERVICE_BASE_NAME, settings.getServiceBaseName())
.append(usageDateFromFieldName(), usageDateStart)
.append(usageDateToFieldName(), usageDateEnd)
.append(ITEMS, reportItems)
.append(COST_TOTAL, BillingCalculationUtils.formatDouble(BillingCalculationUtils.round
(costTotal, 2)))
.append(currencyCodeFieldName(), (reportItems.isEmpty() ? null :
reportItems.get(0).getString(currencyCodeFieldName())))
.append(FULL_REPORT, fullReport);
}
protected String currencyCodeFieldName() {
return "currency_code";
}
protected String usageDateToFieldName() {
return MongoKeyWords.USAGE_TO;
}
protected String costFieldName() {
return MongoKeyWords.COST;
}
protected String productFieldName() {
return ReportLine.FIELD_PRODUCT;
}
protected String usageDateFromFieldName() {
return MongoKeyWords.USAGE_FROM;
}
protected String dlabIdFieldName() {
return ReportLine.FIELD_DLAB_ID;
}
protected String shapeFieldName() {
return SHAPE;
}
protected abstract Bson sortCriteria();
protected abstract Bson groupCriteria();
protected Map<String, ShapeInfo> getShapes(List<String> shapeNames) {
FindIterable<Document> userInstances = getUserInstances();
final Map<String, ShapeInfo> shapes = new HashMap<>();
for (Document d : userInstances) {
getExploratoryShape(shapeNames, d)
.ifPresent(shapeInfo -> shapes.put(d.getString(EXPLORATORY_ID), shapeInfo));
@SuppressWarnings("unchecked")
List<Document> comp = (List<Document>) d.get(COMPUTATIONAL_RESOURCES);
comp.forEach(computational ->
getComputationalShape(shapeNames, computational)
.ifPresent(shapeInfo -> shapes.put(computational.getString(COMPUTATIONAL_ID), shapeInfo)));
}
appendSsnAndEdgeNodeType(shapeNames, shapes);
log.trace("Loaded shapes is {}", shapes);
return shapes;
}
@Override
public Double getTotalCost() {
return aggregateBillingData(singletonList(group(null, sum(TOTAL_FIELD_NAME, COST_FIELD))));
}
@Override
public Double getUserCost(String user) {
final List<Bson> pipeline = Arrays.asList(match(eq(USER, user)),
group(null, sum(TOTAL_FIELD_NAME, COST_FIELD)));
return aggregateBillingData(pipeline);
}
@Override
public int getBillingQuoteUsed() {
return toPercentage(() -> settings.getMaxBudget(), getTotalCost());
}
@Override
public int getBillingUserQuoteUsed(String user) {
return toPercentage(() -> userSettingsDAO.getAllowedBudget(user), getUserCost(user));
}
@Override
public boolean isBillingQuoteReached() {
return getBillingQuoteUsed() >= ONE_HUNDRED;
}
@Override
public boolean isUserQuoteReached(String user) {
final Double userCost = getUserCost(user);
return userSettingsDAO.getAllowedBudget(user)
.filter(allowedBudget -> userCost.intValue() != 0 && allowedBudget <= userCost)
.isPresent();
}
protected String getUserOrDefault(String user) {
return StringUtils.isNotBlank(user) ? user : SHARED_RESOURCE_NAME;
}
private Integer toPercentage(Supplier<Optional<Integer>> allowedBudget, Double totalCost) {
return allowedBudget.get()
.map(userBudget -> (totalCost * ONE_HUNDRED) / userBudget)
.map(Double::intValue)
.orElse(BigDecimal.ZERO.intValue());
}
private List<Bson> matchCriteria(BillingFilter filter) {
List<Bson> searchCriteria = new ArrayList<>();
if (filter.getUser() != null && !filter.getUser().isEmpty()) {
searchCriteria.add(Filters.in(MongoKeyWords.DLAB_USER, filter.getUser()));
}
if (filter.getResourceType() != null && !filter.getResourceType().isEmpty()) {
searchCriteria.add(Filters.in("dlab_resource_type",
DlabResourceType.getResourceTypeIds(filter.getResourceType())));
}
if (filter.getDlabId() != null && !filter.getDlabId().isEmpty()) {
searchCriteria.add(regex(dlabIdFieldName(), filter.getDlabId(), "i"));
}
if (filter.getDateStart() != null && !filter.getDateStart().isEmpty()) {
searchCriteria.add(gte(MongoKeyWords.USAGE_DAY, filter.getDateStart()));
searchCriteria.add(gte(FIELD_USAGE_DATE, filter.getDateStart()));
}
if (filter.getDateEnd() != null && !filter.getDateEnd().isEmpty()) {
searchCriteria.add(lte(MongoKeyWords.USAGE_DAY, filter.getDateEnd()));
searchCriteria.add(lte(FIELD_USAGE_DATE, filter.getDateEnd()));
}
searchCriteria.addAll(cloudMatchCriteria((T) filter));
return searchCriteria;
}
protected abstract List<Bson> cloudMatchCriteria(T filter);
private Optional<ShapeInfo> getComputationalShape(List<String> shapeNames, Document c) {
return isDataEngine(c.getString(DATAENGINE_DOCKER_IMAGE)) ? getDataEngineShape(shapeNames, c) :
getDataEngineServiceShape(shapeNames, c);
}
private Double aggregateBillingData(List<Bson> pipeline) {
return Optional.ofNullable(aggregate(BILLING, pipeline).first())
.map(d -> d.getDouble(TOTAL_FIELD_NAME))
.orElse(BigDecimal.ZERO.doubleValue());
}
private FindIterable<Document> getUserInstances() {
return getCollection(USER_INSTANCES)
.find()
.projection(
fields(excludeId(),
include(SHAPE, EXPLORATORY_ID, STATUS,
COMPUTATIONAL_RESOURCES + "." + COMPUTATIONAL_ID,
COMPUTATIONAL_RESOURCES + "." + MASTER_NODE_SHAPE,
COMPUTATIONAL_RESOURCES + "." + SLAVE_NODE_SHAPE,
COMPUTATIONAL_RESOURCES + "." + TOTAL_INSTANCE_NUMBER,
COMPUTATIONAL_RESOURCES + "." + DATAENGINE_SHAPE,
COMPUTATIONAL_RESOURCES + "." + DATAENGINE_INSTANCE_COUNT,
COMPUTATIONAL_RESOURCES + "." + DATAENGINE_DOCKER_IMAGE,
COMPUTATIONAL_RESOURCES + "." + STATUS
)));
}
private Optional<ShapeInfo> getExploratoryShape(List<String> shapeNames, Document d) {
final String shape = d.getString(SHAPE);
if (isShapeAcceptable(shapeNames, shape)) {
return Optional.of(new ShapeInfo(shape, UserInstanceStatus.of(d.getString(STATUS))));
}
return Optional.empty();
}
private boolean isDataEngine(String dockerImage) {
return DataEngineType.fromDockerImageName(dockerImage) == DataEngineType.SPARK_STANDALONE;
}
private Optional<ShapeInfo> getDataEngineServiceShape(List<String> shapeNames,
Document c) {
final String desMasterShape = c.getString(MASTER_NODE_SHAPE);
final String desSlaveShape = c.getString(SLAVE_NODE_SHAPE);
if (isShapeAcceptable(shapeNames, desMasterShape, desSlaveShape)) {
return Optional.of(new ShapeInfo(desMasterShape, desSlaveShape, c.getString(TOTAL_INSTANCE_NUMBER),
UserInstanceStatus.of(c.getString(STATUS))));
}
return Optional.empty();
}
private Optional<ShapeInfo> getDataEngineShape(List<String> shapeNames, Document c) {
final String dataEngineShape = c.getString(DATAENGINE_SHAPE);
if ((isShapeAcceptable(shapeNames, dataEngineShape))
&& StringUtils.isNotEmpty(c.getString(COMPUTATIONAL_ID))) {
return Optional.of(new ShapeInfo(dataEngineShape, c.getString(DATAENGINE_INSTANCE_COUNT),
UserInstanceStatus.of(c.getString(STATUS))));
}
return Optional.empty();
}
private boolean isShapeAcceptable(List<String> shapeNames, String... shapes) {
return shapeNames == null || shapeNames.isEmpty() || Arrays.stream(shapes).anyMatch(shapeNames::contains);
}
protected void appendSsnAndEdgeNodeType(List<String> shapeNames, Map<String, ShapeInfo> shapes) {
final String ssnShape = getSsnShape();
if (shapeNames == null || shapeNames.isEmpty() || shapeNames.contains(ssnShape)) {
String serviceBaseName = getServiceBaseName();
shapes.put(serviceBaseName + "-ssn", new ShapeInfo(ssnShape, UserInstanceStatus.RUNNING));
FindIterable<Document> docs = getCollection(USER_EDGE)
.find()
.projection(fields(include(ID, EDGE_STATUS)));
for (Document d : docs) {
shapes.put(edgeId(d),
new ShapeInfo(getEdgeSize(), UserInstanceStatus.of(d.getString(EDGE_STATUS))));
}
}
}
protected String getServiceBaseName() {
return settings.getServiceBaseName();
}
protected abstract String getEdgeSize();
protected abstract String edgeId(Document d);
protected abstract String getSsnShape();
protected String generateShapeName(ShapeInfo shape) {
return Optional.ofNullable(shape).map(ShapeInfo::getName).orElse(StringUtils.EMPTY);
}
protected void usersToLowerCase(List<String> users) {
if (users != null) {
users.replaceAll(u -> u != null ? u.toLowerCase() : null);
}
}
protected void setUserFilter(UserInfo userInfo, BillingFilter filter, boolean isFullReport) {
if (isFullReport) {
usersToLowerCase(filter.getUser());
} else {
filter.setUser(Lists.newArrayList(userInfo.getName().toLowerCase()));
}
}
/**
* Store shape info
*/
@Getter
@ToString
protected class ShapeInfo {
private static final String DES_NAME_FORMAT = "Master: %s%sSlave: %d x %s";
private static final String DE_NAME_FORMAT = "%d x %s";
private final boolean isDataEngine;
private final String shape;
private final String slaveShape;
private final String slaveCount;
private final boolean isExploratory;
private final UserInstanceStatus status;
private ShapeInfo(boolean isDataEngine, String shape, String slaveShape, String slaveCount, boolean
isExploratory, UserInstanceStatus status) {
this.isDataEngine = isDataEngine;
this.shape = shape;
this.slaveShape = slaveShape;
this.slaveCount = slaveCount;
this.isExploratory = isExploratory;
this.status = status;
}
public ShapeInfo(String shape, UserInstanceStatus status) {
this(false, shape, null, null, true, status);
}
ShapeInfo(String shape, String slaveShape, String slaveCount, UserInstanceStatus status) {
this(false, shape, slaveShape, slaveCount, false, status);
}
ShapeInfo(String shape, String slaveCount, UserInstanceStatus status) {
this(true, shape, null, slaveCount, false, status);
}
public String getName() {
if (isExploratory) {
return shape;
} else {
return clusterName();
}
}
private String clusterName() {
try {
final Integer count = Integer.valueOf(slaveCount);
return isDataEngine ? String.format(DE_NAME_FORMAT, count, shape) :
String.format(DES_NAME_FORMAT, shape, System.lineSeparator(), count - 1, slaveShape);
} catch (NumberFormatException e) {
log.error("Cannot parse string {} to integer", slaveCount);
return StringUtils.EMPTY;
}
}
}
}