Skip to content

Commit

Permalink
✨ : add cost estimation for stacks
Browse files Browse the repository at this point in the history
  • Loading branch information
juwit committed Aug 9, 2019
1 parent a3ef489 commit f373ae9
Show file tree
Hide file tree
Showing 6 changed files with 295 additions and 3 deletions.
10 changes: 10 additions & 0 deletions src/main/java/io/codeka/gaia/bo/Stack.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.codeka.gaia.teams.bo.Team;
import org.springframework.data.mongodb.core.mapping.DBRef;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;

Expand Down Expand Up @@ -49,6 +50,8 @@ public class Stack {
@DBRef
private Team ownerTeam;

private BigDecimal estimatedRunningCost;

public String getId() {
return id;
}
Expand Down Expand Up @@ -109,4 +112,11 @@ public void setOwnerTeam(Team ownerTeam) {
this.ownerTeam = ownerTeam;
}

public BigDecimal getEstimatedRunningCost() {
return estimatedRunningCost;
}

public void setEstimatedRunningCost(BigDecimal estimatedRunningCost) {
this.estimatedRunningCost = estimatedRunningCost;
}
}
15 changes: 12 additions & 3 deletions src/main/java/io/codeka/gaia/controller/StackRestController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.codeka.gaia.bo.Stack;
import io.codeka.gaia.repository.StackRepository;
import io.codeka.gaia.service.StackCostCalculator;
import io.codeka.gaia.teams.bo.Team;
import io.codeka.gaia.teams.bo.User;
import org.springframework.beans.factory.annotation.Autowired;
Expand All @@ -17,9 +18,12 @@ public class StackRestController {

private StackRepository stackRepository;

private StackCostCalculator stackCostCalculator;

@Autowired
public StackRestController(StackRepository stackRepository) {
public StackRestController(StackRepository stackRepository, StackCostCalculator stackCostCalculator) {
this.stackRepository = stackRepository;
this.stackCostCalculator = stackCostCalculator;
}

@GetMapping
Expand All @@ -32,10 +36,15 @@ public List<Stack> listStacks(User user){

@GetMapping("/{id}")
public Stack getStack(@PathVariable String id, User user){
Stack stack;
if(user.isAdmin()){
return stackRepository.findById(id).orElseThrow(StackNotFoundException::new);
stack = stackRepository.findById(id).orElseThrow(StackNotFoundException::new);
}
else{
stack = stackRepository.findByIdAndOwnerTeam(id, user.getTeam()).orElseThrow(StackNotFoundException::new);
}
return stackRepository.findByIdAndOwnerTeam(id, user.getTeam()).orElseThrow(StackNotFoundException::new);
stack.setEstimatedRunningCost(stackCostCalculator.calculateRunningCostEstimation(stack));
return stack;
}

@PostMapping()
Expand Down
84 changes: 84 additions & 0 deletions src/main/java/io/codeka/gaia/service/StackCostCalculator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package io.codeka.gaia.service;

import io.codeka.gaia.bo.JobStatus;
import io.codeka.gaia.bo.JobType;
import io.codeka.gaia.bo.Stack;
import io.codeka.gaia.repository.JobRepository;
import io.codeka.gaia.repository.TerraformModuleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDateTime;

/**
* This service calculates the cost of the running stack
*/
@Service
public class StackCostCalculator {

private JobRepository jobRepository;

private TerraformModuleRepository moduleRepository;

@Autowired
public StackCostCalculator(JobRepository jobRepository, TerraformModuleRepository moduleRepository) {
this.jobRepository = jobRepository;
this.moduleRepository = moduleRepository;
}

/**
* Calculates an estimation of what this stack has cost in total
* @param stack
* @return
*/
public BigDecimal calculateRunningCostEstimation(Stack stack){
var jobs = jobRepository.findAllByStackId(stack.getId());

if(jobs.isEmpty()){
return BigDecimal.ZERO;
}

var module = moduleRepository.findById(stack.getModuleId()).orElseThrow();

if(module.getEstimatedMonthlyCost() == null){
return BigDecimal.ZERO;
}

// calculate total running time
var duration = Duration.ofDays(0);

var start = LocalDateTime.now();
var end = LocalDateTime.now();

for(var job : jobs ){
// add
if(job.getType() == JobType.RUN && job.getStatus() == JobStatus.FINISHED){
start =job.getStartDateTime();
end = LocalDateTime.now();
}
if(job.getType() == JobType.STOP && job.getStatus() == JobStatus.FINISHED){
end = job.getStartDateTime();
duration = duration.plus(Duration.between(start, end));

// reset start and end
start = LocalDateTime.now();
end = LocalDateTime.now();
}
}

// add last duration
duration = duration.plus(Duration.between(start, end));

// get hourly cost
var hourlyCost = module.getEstimatedMonthlyCost().divide(BigDecimal.valueOf(31L*24L), 4, RoundingMode.HALF_UP);

// get total cost
return hourlyCost.multiply(BigDecimal.valueOf(duration.toHours()))
// round it to 2 decimals
.setScale(2, RoundingMode.HALF_UP);
}

}
1 change: 1 addition & 0 deletions src/main/resources/templates/stack.html
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ <h2>Outputs</h2>
<div class="block_head">
<h2>Stack {{stack.name}}</h2>
<small>{{stack.description}}</small>
<p v-if="stack.estimatedRunningCost">Estimated total running cost : <b-badge variant="info">{{stack.estimatedRunningCost}} $</b-badge></p>
<h2>
<span class="badge badge-pill badge-success" v-if="stack.state === 'NEW'" data-toggle="tooltip" title="Your stack is new and has not been started yet."><i class="fas fa-star-of-life"></i> new</span>
<span class="badge badge-pill badge-primary" v-if="stack.state === 'RUNNING'" data-toggle="tooltip" title="Your stack is up and running !"><i class="far fa-check-square"></i> running</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.codeka.gaia.bo.Stack;
import io.codeka.gaia.repository.StackRepository;
import io.codeka.gaia.service.StackCostCalculator;
import io.codeka.gaia.teams.bo.Team;
import io.codeka.gaia.teams.bo.User;
import org.junit.jupiter.api.BeforeEach;
Expand Down Expand Up @@ -36,6 +37,9 @@ class StackRestControllerTest {
@Mock
private StackRepository stackRepository;

@Mock
private StackCostCalculator stackCostCalculator;

@BeforeEach
void setUp() {
standardUser.setTeam(userTeam);
Expand Down Expand Up @@ -83,6 +87,19 @@ void getStack_shouldFindStack_forStandardUser(){
verify(stackRepository).findByIdAndOwnerTeam("42", userTeam);
}

@Test
void getStack_shouldCalculateRunningCost_forStandardUser(){
// given
when(stackRepository.findByIdAndOwnerTeam("42", userTeam)).thenReturn(Optional.of(stack));

// when
stackRestController.getStack("42", standardUser);

// then
verify(stackRepository).findByIdAndOwnerTeam("42", userTeam);
verify(stackCostCalculator).calculateRunningCostEstimation(stack);
}

@Test
void getStack_shouldThrowStackNotFoundException(){
// given
Expand Down
171 changes: 171 additions & 0 deletions src/test/java/io/codeka/gaia/service/StackCostCalculatorTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package io.codeka.gaia.service;

import io.codeka.gaia.bo.Job;
import io.codeka.gaia.bo.JobType;
import io.codeka.gaia.bo.Stack;
import io.codeka.gaia.bo.TerraformModule;
import io.codeka.gaia.repository.JobRepository;
import io.codeka.gaia.repository.TerraformModuleRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class StackCostCalculatorTest {

@Mock
private JobRepository jobRepository;

@Mock
private TerraformModuleRepository moduleRepository;

@InjectMocks
private StackCostCalculator calculator;

@Test
void stacksWithNoJob_shouldHaveZeroCost(){
// given
var stack = new Stack();
stack.setId("12");

when(jobRepository.findAllByStackId("12")).thenReturn(Collections.EMPTY_LIST);

// when
var cost = calculator.calculateRunningCostEstimation(stack);

// then
assertEquals(BigDecimal.ZERO, cost);
}

@Test
void stacksWithOneRunJob_shouldHaveCostEqualToRunningTime(){
// given
var stack = new Stack();
stack.setId("12");
stack.setModuleId("42");

var module = new TerraformModule();
module.setEstimatedMonthlyCost(BigDecimal.valueOf(31));
when(moduleRepository.findById("42")).thenReturn(Optional.of(module));

// a job started two days ago
var job = new Job();
job.start(JobType.RUN);
job.end();
job.setStartDateTime(LocalDateTime.now().minusDays(2));
when(jobRepository.findAllByStackId("12")).thenReturn(List.of(job));

// when
var cost = calculator.calculateRunningCostEstimation(stack);

// then
assertEquals(BigDecimal.valueOf(2).setScale(2), cost);
}

@Test
void stacksWithOneRunJobAndOneStopJob_shouldHaveCostEqualToRunningTime(){
// given
var stack = new Stack();
stack.setId("12");
stack.setModuleId("42");

var module = new TerraformModule();
module.setEstimatedMonthlyCost(BigDecimal.valueOf(31));
when(moduleRepository.findById("42")).thenReturn(Optional.of(module));

// a job started two days ago
var job = new Job();
job.start(JobType.RUN);
job.end();
job.setStartDateTime(LocalDateTime.now().minusDays(2));

// a job stopped one day ago
var jobStop = new Job();
jobStop.start(JobType.STOP);
jobStop.end();
jobStop.setStartDateTime(LocalDateTime.now().minusDays(1));

when(jobRepository.findAllByStackId("12")).thenReturn(List.of(job, jobStop));

// when
var cost = calculator.calculateRunningCostEstimation(stack);

// then
assertEquals(BigDecimal.valueOf(1).setScale(2), cost);
}

@Test
void stacksWithOneRunJobAndOneStopJobAndRelaunchedOneHourAgo_shouldHaveCostEqualToRunningTime(){
// given
var stack = new Stack();
stack.setId("12");
stack.setModuleId("42");

var module = new TerraformModule();
module.setEstimatedMonthlyCost(BigDecimal.valueOf(31));
when(moduleRepository.findById("42")).thenReturn(Optional.of(module));

// a job started two days ago
var job = new Job();
job.start(JobType.RUN);
job.end();
job.setStartDateTime(LocalDateTime.now().minusDays(2));

// a job stopped one day ago
var jobStop = new Job();
jobStop.start(JobType.STOP);
jobStop.end();
jobStop.setStartDateTime(LocalDateTime.now().minusDays(1));

// a job started 6 hours ago
var jobRelaunch = new Job();
jobRelaunch.start(JobType.RUN);
jobRelaunch.end();
jobRelaunch.setStartDateTime(LocalDateTime.now().minusHours(1));

when(jobRepository.findAllByStackId("12")).thenReturn(List.of(job, jobStop, jobRelaunch));

// when
var cost = calculator.calculateRunningCostEstimation(stack);

// then
assertEquals(BigDecimal.valueOf(1.04).setScale(2), cost);
}

@Test
void stacksWithModuleHavingNoCost_shouldHaveZeroCost(){
// given
var stack = new Stack();
stack.setId("12");
stack.setModuleId("42");

// a job started two days ago
var job = new Job();
job.start(JobType.RUN);
job.end();
job.setStartDateTime(LocalDateTime.now().minusDays(2));
when(jobRepository.findAllByStackId("12")).thenReturn(List.of(job));

// but a module with no cost
var module = new TerraformModule();
when(moduleRepository.findById("42")).thenReturn(Optional.of(module));

// when
var cost = calculator.calculateRunningCostEstimation(stack);

// then
assertEquals(BigDecimal.ZERO, cost);
}

}

0 comments on commit f373ae9

Please sign in to comment.