app/model/AWSCost.scala (118 lines of code) (raw):
package model
import org.apache.pekko.actor.ActorSystem
import play.api.libs.json._
import scala.jdk.CollectionConverters._
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future
import scala.util.Try
import play.api.libs.json.JsArray
import play.api.libs.json.JsSuccess
import play.api.Logger
import com.amazonaws.services.ec2.model.{DescribeInstancesRequest, DescribeReservedInstancesRequest, Filter}
import lib.{AWS, ScheduledAgent}
import play.api.libs.ws.WSClient
import scala.concurrent.duration._
class AWSCost(implicit wsClient: WSClient, system: ActorSystem) {
val logger = Logger(getClass)
implicit val awsConnection = AWS.connection
def apply(costType: EC2CostingType) = {
val onDemandRate: BigDecimal = onDemandPriceFor(costType)
val totalInstances: Int = countForType(costType)
val reservations = reservationsFor(costType)
val reservationCount: Int = reservations.map(_.count).sum
val reservationRate: Double = reservationCount match {
case 0 => 0
case resCount => (reservations.map(_.hourlyCost).sum) / resCount
}
((totalInstances - reservationCount) * onDemandRate + reservationCount * reservationRate) / totalInstances
}
def countForType(costType: EC2CostingType) = typeCounts().getOrElse(costType, 0)
def onDemandPriceFor(costType: EC2CostingType) = {
costsAgent()
.regions(zoneToCostRegion(costType.zone))
.instanceTypes(costType.instanceType)
}
def totalSunkCost = (for {
resList <- reservations.values
res <- resList
} yield res.sunkCost).sum
def reservationsFor(costType: EC2CostingType) = reservations.getOrElse(costType, Seq())
def reservations = reservationsAgent()
val typeCounts = ScheduledAgent[Map[EC2CostingType, Int]](0.seconds, 5.minutes, Map()) {
for {
reservations <- AWS.futureOf(awsConnection.ec2.describeInstancesAsync, new DescribeInstancesRequest())
instances = reservations.getReservations.asScala flatMap (_.getInstances.asScala)
} yield instances.groupBy(i => EC2CostingType(i.getInstanceType, i.getPlacement.getAvailabilityZone)).view.mapValues(_.size).toMap
}
lazy val reservationsAgent = ScheduledAgent[Map[EC2CostingType, Seq[Reservation]]](0.seconds, 5.minutes, Map()) {
logger.info("Starting reservationsAgent")
for {
reservations <- AWS.futureOf(awsConnection.ec2.describeReservedInstancesAsync,
new DescribeReservedInstancesRequest().withFilters(new Filter("state", List("active").asJava)))
} yield {
val reservs = reservations.getReservedInstances.asScala.toSeq map { r =>
EC2CostingType(r.getInstanceType, r.getAvailabilityZone) ->
Reservation(r.getInstanceCount, r.getFixedPrice, r.getRecurringCharges.asScala.headOption.map(_.getAmount.toDouble).getOrElse(0d))
}
reservs.foreach{ res => logger.info("Reservation: "+res)}
reservs.groupBy { case (costType, _) => costType }.view.mapValues(_ map { case (_, res) => res }).toMap
}
}
lazy val costsAgent = ScheduledAgent[OnDemandPrices](0.seconds, 30.minutes, OnDemandPrices(Map())) {
// There isn't a proper API for this at time of writing, but handily the
logger.info("Starting costsAgent")
def pricesFromJson(url: String) = wsClient.url(url).withRequestTimeout(2.seconds).get map { response =>
logger.info("Fetched cost data")
implicit object BigDecimalReads extends Reads[BigDecimal]{
def reads(json: JsValue) = JsSuccess(Try { BigDecimal(json.as[String]) } getOrElse (BigDecimal(0)) )
}
implicit object RegionPricesReads extends Reads[RegionPrices] {
def reads(json: JsValue) = {
val JsArray(typeGroups) = json
val typeToCost = for {
group <- typeGroups
JsArray(size) = (group \ "sizes").get
s <- size
} yield {
val JsArray(c) = (s \ "valueColumns").get
(s \ "size").as[String] -> (c.head \ "prices" \ "USD").as[BigDecimal]
}
JsSuccess(RegionPrices(typeToCost.toMap))
}
}
implicit object OnDemandPricesReads extends Reads[OnDemandPrices] {
def reads(json: JsValue) = {
val JsArray(regionsJs) = (json \ "config" \ "regions").get
val regions = regionsJs.map { r =>
(r \ "region").as[String] -> (r \ "instanceTypes").as[RegionPrices]
}
JsSuccess(OnDemandPrices(regions.toMap))
}
}
Json.parse(response.body.dropWhile(_ != '{').takeWhile(_ != ')')).as[OnDemandPrices]
}
for {
current <- pricesFromJson("http://aws.amazon.com/ec2/pricing/json/linux-od.json")
old <- pricesFromJson("https://a0.awsstatic.com/pricing/1/deprecated/ec2/previous-generation/linux-od.json")
} yield current ++ old
}
val zoneToCostRegion: String => String = _.dropRight(1)
}
case class OnDemandPrices(regions: Map[String, RegionPrices]) {
def ++ (other: OnDemandPrices) = OnDemandPrices(regions.foldLeft(other.regions){
case (m, (regionName, regionPrices)) =>
(
for {
myPrices <- m.get(regionName)
} yield m.updated(regionName, RegionPrices(regionPrices.instanceTypes ++ myPrices.instanceTypes))
).getOrElse(m + (regionName -> regionPrices))
})
}
case class RegionPrices(instanceTypes: Map[String, BigDecimal])
case class EC2CostingType(instanceType: String, zone: String)
object EC2CostingType {
implicit val writes = Json.writes[EC2CostingType]
}
case class Reservation(count: Int, fixedPrice: Float, hourlyRate: Double) {
def hourlyCost = count * hourlyRate
def sunkCost = count * fixedPrice
}