From d79928353a2d4cb34168185e123e323e927e9f0f Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 16 Oct 2024 15:21:25 -0300 Subject: [PATCH 1/8] chore: updated crds --- bootstrap/rpc/crds/blockfrostport.json | 1 + bootstrap/rpc/crds/cardanonodeport.json | 18 ++++++++++++++++++ bootstrap/rpc/crds/dbsyncport.json | 1 + bootstrap/rpc/crds/frontends.json | 1 + bootstrap/rpc/crds/kupoport.json | 1 + bootstrap/rpc/crds/marloweport.json | 1 + bootstrap/rpc/crds/mumakport.json | 1 + bootstrap/rpc/crds/ogmiosport.json | 1 + bootstrap/rpc/crds/scrollsport.json | 1 + bootstrap/rpc/crds/submitapiport.json | 1 + bootstrap/rpc/crds/utxorpcport.json | 1 + 11 files changed, 28 insertions(+) diff --git a/bootstrap/rpc/crds/blockfrostport.json b/bootstrap/rpc/crds/blockfrostport.json index ab29d9b..f968994 100644 --- a/bootstrap/rpc/crds/blockfrostport.json +++ b/bootstrap/rpc/crds/blockfrostport.json @@ -1,4 +1,5 @@ { + "cost": {}, "options": [ { "description": "mainnet", diff --git a/bootstrap/rpc/crds/cardanonodeport.json b/bootstrap/rpc/crds/cardanonodeport.json index a69b42f..0de83b8 100644 --- a/bootstrap/rpc/crds/cardanonodeport.json +++ b/bootstrap/rpc/crds/cardanonodeport.json @@ -1,4 +1,22 @@ { + "cost": { + "0": { + "minimum": 0, + "delta": 0 + }, + "1": { + "minimum": 0, + "delta": 0.000013889 + }, + "2": { + "minimum": 100, + "delta": 0.00000463 + }, + "3": { + "minimum": 200, + "delta": 0.000003472 + } + }, "options": [ { "description": "mainnet - stable (9.1.1)", diff --git a/bootstrap/rpc/crds/dbsyncport.json b/bootstrap/rpc/crds/dbsyncport.json index 3dd0586..f641f7d 100644 --- a/bootstrap/rpc/crds/dbsyncport.json +++ b/bootstrap/rpc/crds/dbsyncport.json @@ -1,4 +1,5 @@ { + "cost": {}, "options": [ { "description": "mainnet", diff --git a/bootstrap/rpc/crds/frontends.json b/bootstrap/rpc/crds/frontends.json index 69ae473..3d616ff 100644 --- a/bootstrap/rpc/crds/frontends.json +++ b/bootstrap/rpc/crds/frontends.json @@ -1,4 +1,5 @@ { + "cost": {}, "options": [], "crd": { "apiVersion": "apiextensions.k8s.io/v1", diff --git a/bootstrap/rpc/crds/kupoport.json b/bootstrap/rpc/crds/kupoport.json index 99a9626..503aa31 100644 --- a/bootstrap/rpc/crds/kupoport.json +++ b/bootstrap/rpc/crds/kupoport.json @@ -1,4 +1,5 @@ { + "cost": {}, "options": [ { "description": "mainnet", diff --git a/bootstrap/rpc/crds/marloweport.json b/bootstrap/rpc/crds/marloweport.json index 7290d31..5b71e0e 100644 --- a/bootstrap/rpc/crds/marloweport.json +++ b/bootstrap/rpc/crds/marloweport.json @@ -1,4 +1,5 @@ { + "cost": {}, "options": [ { "description": "mainnet - patch6 (0.0.7)", diff --git a/bootstrap/rpc/crds/mumakport.json b/bootstrap/rpc/crds/mumakport.json index 628848d..0bb39b4 100644 --- a/bootstrap/rpc/crds/mumakport.json +++ b/bootstrap/rpc/crds/mumakport.json @@ -1,4 +1,5 @@ { + "cost": {}, "options": [ { "description": "mainnet", diff --git a/bootstrap/rpc/crds/ogmiosport.json b/bootstrap/rpc/crds/ogmiosport.json index aa09a01..72ea542 100644 --- a/bootstrap/rpc/crds/ogmiosport.json +++ b/bootstrap/rpc/crds/ogmiosport.json @@ -1,4 +1,5 @@ { + "cost": {}, "options": [ { "description": "mainnet", diff --git a/bootstrap/rpc/crds/scrollsport.json b/bootstrap/rpc/crds/scrollsport.json index da01c31..6629d9f 100644 --- a/bootstrap/rpc/crds/scrollsport.json +++ b/bootstrap/rpc/crds/scrollsport.json @@ -1,4 +1,5 @@ { + "cost": {}, "options": [ { "description": "mainnet", diff --git a/bootstrap/rpc/crds/submitapiport.json b/bootstrap/rpc/crds/submitapiport.json index d7dde74..76077a6 100644 --- a/bootstrap/rpc/crds/submitapiport.json +++ b/bootstrap/rpc/crds/submitapiport.json @@ -1,4 +1,5 @@ { + "cost": {}, "options": [ { "description": "mainnet", diff --git a/bootstrap/rpc/crds/utxorpcport.json b/bootstrap/rpc/crds/utxorpcport.json index 41f2c1a..5f63af1 100644 --- a/bootstrap/rpc/crds/utxorpcport.json +++ b/bootstrap/rpc/crds/utxorpcport.json @@ -1,4 +1,5 @@ { + "cost": {}, "options": [ { "description": "mainnet", From a22f1b9d50837f5d5e8afdd70ca04495d03caeac Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 16 Oct 2024 15:24:45 -0300 Subject: [PATCH 2/8] chore: updated test crd --- test/crd/cardanonodeport.json | 173 +++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 1 deletion(-) diff --git a/test/crd/cardanonodeport.json b/test/crd/cardanonodeport.json index 9d6e301..25539bc 100644 --- a/test/crd/cardanonodeport.json +++ b/test/crd/cardanonodeport.json @@ -1 +1,172 @@ -{"apiVersion":"apiextensions.k8s.io/v1","kind":"CustomResourceDefinition","metadata":{"name":"cardanonodeports.demeter.run"},"spec":{"group":"demeter.run","names":{"categories":["demeter-port"],"kind":"CardanoNodePort","plural":"cardanonodeports","shortNames":["cnpts"],"singular":"cardanonodeport"},"scope":"Namespaced","versions":[{"additionalPrinterColumns":[{"jsonPath":".spec.network","name":"Network","type":"string"},{"jsonPath":".spec.version","name":"Version","type":"string"},{"jsonPath":".spec.throughputTier","name":"Throughput Tier","type":"string"},{"jsonPath":".status.authenticatedEndpointUrl","name":"Authenticated Endpoint URL","type":"string"},{"jsonPath":".status.authToken","name":"Auth Token","type":"string"}],"name":"v1alpha1","schema":{"openAPIV3Schema":{"description":"Auto-generated derived type for CardanoNodePortSpec via `CustomResource`","properties":{"spec":{"properties":{"authToken":{"nullable":true,"type":"string"},"network":{"type":"string"},"throughputTier":{"type":"string"},"version":{"type":"string"}},"required":["network","throughputTier","version"],"type":"object"},"status":{"nullable":true,"properties":{"authToken":{"type":"string"},"authenticatedEndpointUrl":{"type":"string"}},"required":["authToken","authenticatedEndpointUrl"],"type":"object"}},"required":["spec"],"title":"CardanoNodePort","type":"object"}},"served":true,"storage":true,"subresources":{"status":{}}}]}} \ No newline at end of file +{ + "cost": { + "0": { + "fixed": 0, + "usageBased": 0 + }, + "1": { + "fixed": 0, + "usageBased": 1.2 + }, + "2": { + "fixed": 100, + "usageBased": 0.4 + }, + "3": { + "fixed": 200, + "usageBased": 0.3 + } + }, + "options": [ + { + "description": "mainnet - stable (9.1.1)", + "spec": { + "network": "mainnet", + "version": "stable", + "throughputTier": "0" + } + }, + { + "description": "preprod - stable (9.1.1)", + "spec": { + "network": "preprod", + "version": "stable", + "throughputTier": "0" + } + }, + { + "description": "preview - stable (9.1.1)", + "spec": { + "network": "preview", + "version": "stable", + "throughputTier": "0" + } + }, + { + "description": "vector-testnet - stable (8.7.3)", + "spec": { + "network": "vector-testnet", + "version": "stable", + "throughputTier": "0" + } + }, + { + "description": "prime-testnet - stable (8.7.3)", + "spec": { + "network": "prime-testnet", + "version": "stable", + "throughputTier": "0" + } + } + ], + "crd": { + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "cardanonodeports.demeter.run" + }, + "spec": { + "group": "demeter.run", + "names": { + "categories": [ + "demeter-port" + ], + "kind": "CardanoNodePort", + "plural": "cardanonodeports", + "shortNames": [ + "cnpts" + ], + "singular": "cardanonodeport" + }, + "scope": "Namespaced", + "versions": [ + { + "additionalPrinterColumns": [ + { + "jsonPath": ".spec.network", + "name": "Network", + "type": "string" + }, + { + "jsonPath": ".spec.version", + "name": "Version", + "type": "string" + }, + { + "jsonPath": ".spec.throughputTier", + "name": "Throughput Tier", + "type": "string" + }, + { + "jsonPath": ".status.authenticatedEndpointUrl", + "name": "Authenticated Endpoint URL", + "type": "string" + }, + { + "jsonPath": ".status.authToken", + "name": "Auth Token", + "type": "string" + } + ], + "name": "v1alpha1", + "schema": { + "openAPIV3Schema": { + "description": "Auto-generated derived type for CardanoNodePortSpec via `CustomResource`", + "properties": { + "spec": { + "properties": { + "authToken": { + "nullable": true, + "type": "string" + }, + "network": { + "type": "string" + }, + "throughputTier": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "network", + "throughputTier", + "version" + ], + "type": "object" + }, + "status": { + "nullable": true, + "properties": { + "authToken": { + "type": "string" + }, + "authenticatedEndpointUrl": { + "type": "string" + } + }, + "required": [ + "authToken", + "authenticatedEndpointUrl" + ], + "type": "object" + } + }, + "required": [ + "spec" + ], + "title": "CardanoNodePort", + "type": "object" + } + }, + "served": true, + "storage": true, + "subresources": { + "status": {} + } + } + ] + } + } +} From 5322eea24e91bc8b9f7856c49afa296f112981dd Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 16 Oct 2024 15:25:14 -0300 Subject: [PATCH 3/8] feat: implemented usage compute cost --- Cargo.lock | 22 ++++++++- Cargo.toml | 1 + src/domain/metadata/command.rs | 2 +- src/domain/metadata/mod.rs | 19 ++++---- src/domain/resource/command.rs | 2 +- src/domain/usage/cache.rs | 6 +-- src/domain/usage/command.rs | 45 ++++++++++++++++--- src/domain/usage/mod.rs | 82 +++++++++++++++++++++++++--------- src/driven/cache/usage.rs | 34 ++++++-------- src/driven/metadata/mod.rs | 39 ++++++++++++++-- src/drivers/billing/mod.rs | 35 ++++++++++++--- src/drivers/grpc/mod.rs | 3 +- src/drivers/grpc/usage.rs | 17 +++++-- 13 files changed, 233 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 25fd939..40eb790 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1094,7 +1094,7 @@ dependencies = [ [[package]] name = "dmtri" version = "0.1.0" -source = "git+https://github.com/demeter-run/specs.git#69e7b9189f6c5d22bd4915b3a481591c30e625d1" +source = "git+https://github.com/demeter-run/specs.git#9d5c598077508410f4b804d31da296a9a1dfe680" dependencies = [ "bytes 1.7.2", "pbjson", @@ -1220,6 +1220,7 @@ dependencies = [ "dotenv", "futures 0.3.31", "handlebars", + "include_dir", "json-patch", "jsonwebtoken", "k8s-openapi", @@ -2050,6 +2051,25 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "1.9.3" diff --git a/Cargo.toml b/Cargo.toml index b09edb5..9b7b81c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ comfy-table = "7.1.1" csv = "1.3.0" handlebars = "6.1.0" slack-hook = "0.8.0" +include_dir = "0.7.4" [dev-dependencies] mockall = "0.12.1" diff --git a/src/domain/metadata/command.rs b/src/domain/metadata/command.rs index 6f466cb..5036bda 100644 --- a/src/domain/metadata/command.rs +++ b/src/domain/metadata/command.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::{MetadataDriven, ResourceMetadata, Result}; pub async fn fetch(metadata: Arc) -> Result> { - metadata.find().await + metadata.find() } #[cfg(test)] diff --git a/src/domain/metadata/mod.rs b/src/domain/metadata/mod.rs index 67d7e42..cb4b893 100644 --- a/src/domain/metadata/mod.rs +++ b/src/domain/metadata/mod.rs @@ -1,4 +1,4 @@ -use std::str::FromStr; +use std::{collections::HashMap, str::FromStr}; use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; use serde::{Deserialize, Serialize}; @@ -8,10 +8,9 @@ use super::{error::Error, Result}; pub mod command; #[cfg_attr(test, mockall::automock)] -#[async_trait::async_trait] pub trait MetadataDriven: Send + Sync { - async fn find(&self) -> Result>; - async fn find_by_kind(&self, kind: &str) -> Result>; + fn find(&self) -> Result>; + fn find_by_kind(&self, kind: &str) -> Result>; fn render_hbs(&self, name: &str, spec: &str) -> Result; } @@ -33,8 +32,15 @@ impl FromStr for KnownField { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceMetadataCost { + pub minimum: i64, + pub delta: f64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceMetadata { + pub cost: HashMap, pub options: serde_json::Value, pub crd: CustomResourceDefinition, } @@ -50,10 +56,7 @@ pub mod tests { impl Default for ResourceMetadata { fn default() -> Self { - Self { - crd: serde_json::from_str(CARDANO_NODE_PORT_CRD).unwrap(), - options: serde_json::Value::default(), - } + serde_json::from_str(CARDANO_NODE_PORT_CRD).unwrap() } } } diff --git a/src/domain/resource/command.rs b/src/domain/resource/command.rs index 77fde3b..1f99bd0 100644 --- a/src/domain/resource/command.rs +++ b/src/domain/resource/command.rs @@ -101,7 +101,7 @@ pub async fn create( return Err(Error::Unexpected("invalid random name, try again".into())); } - let Some(metadata) = metadata.find_by_kind(&cmd.kind).await? else { + let Some(metadata) = metadata.find_by_kind(&cmd.kind)? else { return Err(Error::CommandMalformed("kind not supported".into())); }; diff --git a/src/domain/usage/cache.rs b/src/domain/usage/cache.rs index dc157fb..9cfbabf 100644 --- a/src/domain/usage/cache.rs +++ b/src/domain/usage/cache.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use crate::domain::{event::UsageCreated, Result}; -use super::{Usage, UsageReport, UsageReportAggregated, UsageResource}; +use super::{Usage, UsageReport, UsageResource}; #[cfg_attr(test, mockall::automock)] #[async_trait::async_trait] @@ -13,7 +13,7 @@ pub trait UsageDrivenCache: Send + Sync { page: &u32, page_size: &u32, ) -> Result>; - async fn find_report_aggregated(&self, period: &str) -> Result>; + async fn find_report_aggregated(&self, period: &str) -> Result>; async fn find_resouces(&self) -> Result>; async fn create(&self, usage: Vec) -> Result<()>; } @@ -25,7 +25,7 @@ pub async fn create(cache: Arc, evt: UsageCreated) -> Resu pub async fn find_report_aggregated( cache: Arc, period: &str, -) -> Result> { +) -> Result> { cache.find_report_aggregated(period).await } diff --git a/src/domain/usage/command.rs b/src/domain/usage/command.rs index 2cdc534..7a45eb6 100644 --- a/src/domain/usage/command.rs +++ b/src/domain/usage/command.rs @@ -3,15 +3,17 @@ use std::sync::Arc; use crate::domain::{ auth::{assert_permission, Credential}, error::Error, + metadata::MetadataDriven, project::cache::ProjectDrivenCache, Result, PAGE_SIZE_DEFAULT, PAGE_SIZE_MAX, }; -use super::{cache::UsageDrivenCache, UsageReport}; +use super::{cache::UsageDrivenCache, UsageReport, UsageReportImpl}; pub async fn fetch_report( project_cache: Arc, usage_cache: Arc, + metadata: Arc, cmd: FetchCmd, ) -> Result> { assert_permission( @@ -22,9 +24,12 @@ pub async fn fetch_report( ) .await?; - usage_cache + let usage = usage_cache .find_report(&cmd.project_id, &cmd.page, &cmd.page_size) - .await + .await? + .calculate_cost(metadata.clone()); + + Ok(usage) } #[derive(Debug, Clone)] @@ -65,6 +70,7 @@ mod tests { use super::*; use crate::domain::{ + metadata::{MockMetadataDriven, ResourceMetadata}, project::{cache::MockProjectDrivenCache, ProjectUser}, usage::cache::MockUsageDrivenCache, }; @@ -92,9 +98,20 @@ mod tests { .expect_find_report() .return_once(|_, _, _| Ok(vec![UsageReport::default()])); + let mut metadata = MockMetadataDriven::new(); + metadata + .expect_find_by_kind() + .return_once(|_| Ok(Some(ResourceMetadata::default()))); + let cmd = FetchCmd::default(); - let result = fetch_report(Arc::new(project_cache), Arc::new(usage_cache), cmd).await; + let result = fetch_report( + Arc::new(project_cache), + Arc::new(usage_cache), + Arc::new(metadata), + cmd, + ) + .await; assert!(result.is_ok()); } #[tokio::test] @@ -108,7 +125,15 @@ mod tests { let cmd = FetchCmd::default(); - let result = fetch_report(Arc::new(project_cache), Arc::new(usage_cache), cmd).await; + let metadata = MockMetadataDriven::new(); + + let result = fetch_report( + Arc::new(project_cache), + Arc::new(usage_cache), + Arc::new(metadata), + cmd, + ) + .await; assert!(result.is_err()); } #[tokio::test] @@ -121,7 +146,15 @@ mod tests { ..Default::default() }; - let result = fetch_report(Arc::new(project_cache), Arc::new(usage_cache), cmd).await; + let metadata = MockMetadataDriven::new(); + + let result = fetch_report( + Arc::new(project_cache), + Arc::new(usage_cache), + Arc::new(metadata), + cmd, + ) + .await; assert!(result.is_err()); } } diff --git a/src/domain/usage/mod.rs b/src/domain/usage/mod.rs index 8445ec3..23ab964 100644 --- a/src/domain/usage/mod.rs +++ b/src/domain/usage/mod.rs @@ -1,7 +1,10 @@ -use chrono::{DateTime, Utc}; +use std::sync::Arc; + +use chrono::{DateTime, Datelike, Utc}; +use tracing::{error, warn}; use uuid::Uuid; -use super::event::UsageCreated; +use super::{event::UsageCreated, metadata::MetadataDriven}; pub mod cache; pub mod cluster; @@ -49,15 +52,64 @@ pub struct UsageUnitMetric { pub interval: u64, } -#[derive(Debug)] +pub trait UsageReportImpl { + fn calculate_cost(&mut self, metadata: Arc) -> Self; +} +#[derive(Debug, Clone)] pub struct UsageReport { + pub project_id: String, + pub project_namespace: String, + pub project_billing_provider: String, + pub project_billing_provider_id: String, pub resource_id: String, pub resource_kind: String, pub resource_name: String, pub resource_spec: String, pub tier: String, pub units: i64, + pub interval: i64, pub period: String, + pub units_cost: Option, + pub minimum_cost: Option, +} +impl UsageReportImpl for Vec { + fn calculate_cost(&mut self, metadata: Arc) -> Self { + let now = chrono::Utc::now(); + let next_month = if now.month() == 12 { + chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1).unwrap() + } else { + chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1).unwrap() + }; + let first_day = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1).unwrap(); + let days = (next_month - first_day).num_days(); + let max_interval = days * 24 * 60 * 60; + + self.into_iter().for_each(|usage| { + match metadata.find_by_kind(&usage.resource_kind) { + Ok(metadata) => match metadata { + Some(metadata) => match metadata.cost.get(&usage.tier) { + Some(cost) => { + usage.units_cost = Some((usage.units as f64) * cost.delta); + + if cost.minimum > 0 { + usage.minimum_cost = Some( + ((cost.minimum as f64) / (max_interval as f64)) + * (usage.interval as f64), + ); + } + } + None => { + warn!("tier cost not found for the kind {}", usage.resource_kind) + } + }, + None => warn!("metadata not found for the kind {}", usage.resource_kind), + }, + Err(error) => error!(?error, "fail to find the metadata"), + }; + }); + + self.to_vec() + } } #[derive(Debug)] @@ -76,23 +128,6 @@ pub struct UsageResourceUnit { pub interval: u64, } -#[derive(Debug)] -pub struct UsageReportAggregated { - pub project_id: String, - pub project_namespace: String, - #[allow(dead_code)] - pub project_billing_provider: String, - pub project_billing_provider_id: String, - pub resource_id: String, - pub resource_kind: String, - pub resource_name: String, - pub tier: String, - pub interval: u64, - pub units: i64, - #[allow(dead_code)] - pub period: String, -} - #[cfg(test)] mod tests { use uuid::Uuid; @@ -118,6 +153,10 @@ mod tests { impl Default for UsageReport { fn default() -> Self { Self { + project_id: Uuid::new_v4().to_string(), + project_namespace: "xxx".into(), + project_billing_provider: "stripe".into(), + project_billing_provider_id: "xxx".into(), resource_id: Uuid::new_v4().to_string(), resource_kind: "CardanoNodePort".into(), resource_name: format!("cardanonode-{}", utils::get_random_salt()), @@ -125,8 +164,11 @@ mod tests { "{\"version\":\"stable\",\"network\":\"mainnet\",\"throughputTier\":\"1\"}" .into(), units: 120, + interval: 60, tier: "0".into(), period: "08-2024".into(), + units_cost: Some(0.), + minimum_cost: Some(0.), } } } diff --git a/src/driven/cache/usage.rs b/src/driven/cache/usage.rs index 80e573a..d56deff 100644 --- a/src/driven/cache/usage.rs +++ b/src/driven/cache/usage.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::domain::{ resource::ResourceStatus, - usage::{cache::UsageDrivenCache, Usage, UsageReport, UsageReportAggregated, UsageResource}, + usage::{cache::UsageDrivenCache, Usage, UsageReport, UsageResource}, Result, }; @@ -30,6 +30,10 @@ impl UsageDrivenCache for SqliteUsageDrivenCache { let report = sqlx::query_as::<_, UsageReport>( r#" SELECT + p.id as project_id, + p.namespace as project_namespace, + p.billing_provider as project_billing_provider, + p.billing_provider_id as project_billing_provider_id, r.id as resource_id, r.kind as resource_kind, r.name as resource_name, @@ -40,6 +44,7 @@ impl UsageDrivenCache for SqliteUsageDrivenCache { STRFTIME('%m-%Y', 'now') as period FROM "usage" u INNER JOIN resource r ON r.id == u.resource_id + INNER JOIN project p ON p.id == r.project_id WHERE STRFTIME('%m-%Y', u.created_at) = STRFTIME('%m-%Y', 'now') AND r.project_id = $1 GROUP BY resource_id, tier ORDER BY units DESC @@ -56,8 +61,8 @@ impl UsageDrivenCache for SqliteUsageDrivenCache { Ok(report) } - async fn find_report_aggregated(&self, period: &str) -> Result> { - let report_aggregated = sqlx::query_as::<_, UsageReportAggregated>( + async fn find_report_aggregated(&self, period: &str) -> Result> { + let report_aggregated = sqlx::query_as::<_, UsageReport>( r#" SELECT p.id as project_id, @@ -67,6 +72,7 @@ impl UsageDrivenCache for SqliteUsageDrivenCache { r.id as resource_id, r.kind as resource_kind, r.name as resource_name, + r.spec as resource_spec, u.tier as tier, SUM(u.interval) as interval, SUM(u.units) as units, @@ -162,21 +168,6 @@ impl FromRow<'_, SqliteRow> for Usage { impl FromRow<'_, SqliteRow> for UsageReport { fn from_row(row: &SqliteRow) -> sqlx::Result { - Ok(Self { - resource_id: row.try_get("resource_id")?, - resource_kind: row.try_get("resource_kind")?, - resource_name: row.try_get("resource_name")?, - resource_spec: row.try_get("resource_spec")?, - units: row.try_get("units")?, - tier: row.try_get("tier")?, - period: row.try_get("period")?, - }) - } -} - -impl FromRow<'_, SqliteRow> for UsageReportAggregated { - fn from_row(row: &SqliteRow) -> sqlx::Result { - let interval: i64 = row.try_get("interval")?; Ok(Self { project_id: row.try_get("project_id")?, project_namespace: row.try_get("project_namespace")?, @@ -185,10 +176,13 @@ impl FromRow<'_, SqliteRow> for UsageReportAggregated { resource_id: row.try_get("resource_id")?, resource_kind: row.try_get("resource_kind")?, resource_name: row.try_get("resource_name")?, - tier: row.try_get("tier")?, - interval: interval as u64, + resource_spec: row.try_get("resource_spec")?, + interval: row.try_get("interval")?, units: row.try_get("units")?, + tier: row.try_get("tier")?, period: row.try_get("period")?, + minimum_cost: None, + units_cost: None, }) } } diff --git a/src/driven/metadata/mod.rs b/src/driven/metadata/mod.rs index dd48feb..4badaa5 100644 --- a/src/driven/metadata/mod.rs +++ b/src/driven/metadata/mod.rs @@ -1,12 +1,14 @@ use std::{fs, path::Path}; use anyhow::Result as AnyhowResult; +use include_dir::Dir; use crate::domain::{ metadata::{MetadataDriven, ResourceMetadata}, Result, }; +#[derive(Debug)] pub struct FileMetadata<'a> { metadata: Vec, hbs: handlebars::Handlebars<'a>, @@ -47,14 +49,45 @@ impl<'a> FileMetadata<'a> { Ok(Self { metadata, hbs }) } + + pub fn from_dir(dir: Dir) -> AnyhowResult { + let mut metadata: Vec = Vec::new(); + let mut hbs = handlebars::Handlebars::new(); + + for file in dir.files() { + match file.path().extension().and_then(|e| e.to_str()) { + Some("json") => { + metadata.push(serde_json::from_slice(&file.contents())?); + } + Some("hbs") => { + let name = file + .path() + .file_stem() + .unwrap() + .to_str() + .unwrap() + .to_string(); + + let template = match file.contents_utf8() { + Some(template) => template.to_string(), + None => Default::default(), + }; + + hbs.register_template_string(&name, template)?; + } + _ => continue, + }; + } + + Ok(Self { metadata, hbs }) + } } -#[async_trait::async_trait] impl<'a> MetadataDriven for FileMetadata<'a> { - async fn find(&self) -> Result> { + fn find(&self) -> Result> { Ok(self.metadata.clone()) } - async fn find_by_kind(&self, kind: &str) -> Result> { + fn find_by_kind(&self, kind: &str) -> Result> { Ok(self .metadata .clone() diff --git a/src/drivers/billing/mod.rs b/src/drivers/billing/mod.rs index b9b10ef..81b27d4 100644 --- a/src/drivers/billing/mod.rs +++ b/src/drivers/billing/mod.rs @@ -1,5 +1,6 @@ use anyhow::Result; use comfy_table::Table; +use include_dir::{include_dir, Dir}; use serde_json::json; use std::{ collections::HashMap, @@ -9,8 +10,14 @@ use std::{ use tracing::{error, info}; use crate::{ - domain::{self, usage::UsageReportAggregated}, - driven::cache::{usage::SqliteUsageDrivenCache, SqliteCache}, + domain::{ + self, + usage::{UsageReport, UsageReportImpl}, + }, + driven::{ + cache::{usage::SqliteUsageDrivenCache, SqliteCache}, + metadata::FileMetadata, + }, }; pub enum OutputFormat { @@ -19,15 +26,21 @@ pub enum OutputFormat { Csv, } +static METADATA: Dir = include_dir!("bootstrap/rpc/crds"); + pub async fn run(config: BillingConfig, period: &str, output: OutputFormat) -> Result<()> { let sqlite_cache = Arc::new(SqliteCache::new(Path::new(&config.db_path)).await?); sqlite_cache.migrate().await?; let usage_cache = Arc::new(SqliteUsageDrivenCache::new(sqlite_cache.clone())); + let metadata = Arc::new(FileMetadata::from_dir(METADATA.clone())?); + info!("Collecting data"); - let report = domain::usage::cache::find_report_aggregated(usage_cache.clone(), period).await?; + let report = domain::usage::cache::find_report_aggregated(usage_cache.clone(), period) + .await? + .calculate_cost(metadata.clone()); match output { OutputFormat::Table => table(report), @@ -38,7 +51,7 @@ pub async fn run(config: BillingConfig, period: &str, output: OutputFormat) -> R Ok(()) } -fn csv(report: Vec, period: &str) { +fn csv(report: Vec, period: &str) { let path = format!("{period}.csv"); let result = csv::Writer::from_path(&path); if let Err(error) = result { @@ -57,6 +70,8 @@ fn csv(report: Vec, period: &str) { "tier", "time", "units", + "units_cost", + "minimum_cost", ]); if let Err(error) = result { error!(?error); @@ -73,6 +88,8 @@ fn csv(report: Vec, period: &str) { &r.tier, &format!("{:.1}h", ((r.interval as f64) / 60.) / 60.), &r.units.to_string(), + &format!("${:.3}", r.units_cost.unwrap_or(0.)), + &format!("${:.3}", r.minimum_cost.unwrap_or(0.)), ]); if let Err(error) = result { error!(?error); @@ -89,7 +106,7 @@ fn csv(report: Vec, period: &str) { println!("File {} created", path) } -fn json(report: Vec) { +fn json(report: Vec) { let mut json = vec![]; for r in report { @@ -103,13 +120,15 @@ fn json(report: Vec) { "tier": r.tier, "interval": r.interval, "units": r.units, + "units_cost": r.units_cost.unwrap_or(0.), + "minimum_cost": r.minimum_cost.unwrap_or(0.), })) } println!("{}", serde_json::to_string_pretty(&json).unwrap()); } -fn table(report: Vec) { +fn table(report: Vec) { let mut table = Table::new(); table.set_header(vec![ "", @@ -120,6 +139,8 @@ fn table(report: Vec) { "tier", "time", "units", + "units_cost", + "minimum_cost", ]); for (i, r) in report.iter().enumerate() { @@ -132,6 +153,8 @@ fn table(report: Vec) { &r.tier, &format!("{:.1}h", ((r.interval as f64) / 60.) / 60.), &r.units.to_string(), + &format!("${:.3}", r.units_cost.unwrap_or(0.)), + &format!("${:.3}", r.minimum_cost.unwrap_or(0.)), ]); } diff --git a/src/drivers/grpc/mod.rs b/src/drivers/grpc/mod.rs index 525bed9..3669025 100644 --- a/src/drivers/grpc/mod.rs +++ b/src/drivers/grpc/mod.rs @@ -96,7 +96,8 @@ pub async fn server(config: GrpcConfig) -> Result<()> { let metadata_inner = metadata::MetadataServiceImpl::new(metadata.clone()); let metadata_service = MetadataServiceServer::new(metadata_inner); - let usage_inner = usage::UsageServiceImpl::new(project_cache.clone(), usage_cache.clone()); + let usage_inner = + usage::UsageServiceImpl::new(project_cache.clone(), usage_cache.clone(), metadata.clone()); let usage_service = UsageServiceServer::with_interceptor(usage_inner, auth_interceptor.clone()); let address = SocketAddr::from_str(&config.addr)?; diff --git a/src/drivers/grpc/usage.rs b/src/drivers/grpc/usage.rs index 77a7bab..d79c18a 100644 --- a/src/drivers/grpc/usage.rs +++ b/src/drivers/grpc/usage.rs @@ -4,6 +4,7 @@ use tonic::{async_trait, Status}; use crate::domain::{ auth::Credential, + metadata::MetadataDriven, project::cache::ProjectDrivenCache, usage::{cache::UsageDrivenCache, command, UsageReport}, }; @@ -11,15 +12,18 @@ use crate::domain::{ pub struct UsageServiceImpl { pub project_cache: Arc, pub usage_cache: Arc, + pub metadata: Arc, } impl UsageServiceImpl { pub fn new( project_cache: Arc, usage_cache: Arc, + metadata: Arc, ) -> Self { Self { project_cache, usage_cache, + metadata, } } } @@ -39,9 +43,13 @@ impl proto::usage_service_server::UsageService for UsageServiceImpl { let cmd = command::FetchCmd::new(credential, req.project_id, req.page, req.page_size)?; - let usage_report = - command::fetch_report(self.project_cache.clone(), self.usage_cache.clone(), cmd) - .await?; + let usage_report = command::fetch_report( + self.project_cache.clone(), + self.usage_cache.clone(), + self.metadata.clone(), + cmd, + ) + .await?; let records = usage_report.into_iter().map(|v| v.into()).collect(); let message = proto::FetchUsageReportResponse { records }; @@ -59,8 +67,9 @@ impl From for proto::UsageReport { resource_spec: value.resource_spec, units: value.units, tier: value.tier, - cost: 0.0, period: value.period, + units_cost: value.units_cost, + minimum_cost: value.minimum_cost, } } } From 87c0eeb618a2b0cce4c06701b4aa6c19625deb59 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 16 Oct 2024 15:28:26 -0300 Subject: [PATCH 4/8] chore: updated clippy --- src/domain/usage/mod.rs | 2 +- src/driven/metadata/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/usage/mod.rs b/src/domain/usage/mod.rs index 23ab964..5536408 100644 --- a/src/domain/usage/mod.rs +++ b/src/domain/usage/mod.rs @@ -84,7 +84,7 @@ impl UsageReportImpl for Vec { let days = (next_month - first_day).num_days(); let max_interval = days * 24 * 60 * 60; - self.into_iter().for_each(|usage| { + self.iter_mut().for_each(|usage| { match metadata.find_by_kind(&usage.resource_kind) { Ok(metadata) => match metadata { Some(metadata) => match metadata.cost.get(&usage.tier) { diff --git a/src/driven/metadata/mod.rs b/src/driven/metadata/mod.rs index 4badaa5..a4f882c 100644 --- a/src/driven/metadata/mod.rs +++ b/src/driven/metadata/mod.rs @@ -57,7 +57,7 @@ impl<'a> FileMetadata<'a> { for file in dir.files() { match file.path().extension().and_then(|e| e.to_str()) { Some("json") => { - metadata.push(serde_json::from_slice(&file.contents())?); + metadata.push(serde_json::from_slice(file.contents())?); } Some("hbs") => { let name = file From 98a52a106de2e9645e752c48e91365d5ce0a6e5b Mon Sep 17 00:00:00 2001 From: paulobressan Date: Wed, 16 Oct 2024 15:32:44 -0300 Subject: [PATCH 5/8] chore: updated test crd --- test/crd/cardanonodeport.json | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/test/crd/cardanonodeport.json b/test/crd/cardanonodeport.json index 25539bc..695e63d 100644 --- a/test/crd/cardanonodeport.json +++ b/test/crd/cardanonodeport.json @@ -1,20 +1,8 @@ { "cost": { "0": { - "fixed": 0, - "usageBased": 0 - }, - "1": { - "fixed": 0, - "usageBased": 1.2 - }, - "2": { - "fixed": 100, - "usageBased": 0.4 - }, - "3": { - "fixed": 200, - "usageBased": 0.3 + "minimum": 200, + "delta": 0.3 } }, "options": [ From f873e597aab87b8b9ba749ad1f0b5cf6673c6c20 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Thu, 17 Oct 2024 11:30:44 -0300 Subject: [PATCH 6/8] chore: updated prices based on spreadsheets --- bootstrap/rpc/crds/blockfrostport.json | 19 ++++++++++++++++++- bootstrap/rpc/crds/dbsyncport.json | 19 ++++++++++++++++++- bootstrap/rpc/crds/kupoport.json | 19 ++++++++++++++++++- bootstrap/rpc/crds/ogmiosport.json | 19 ++++++++++++++++++- bootstrap/rpc/crds/submitapiport.json | 19 ++++++++++++++++++- 5 files changed, 90 insertions(+), 5 deletions(-) diff --git a/bootstrap/rpc/crds/blockfrostport.json b/bootstrap/rpc/crds/blockfrostport.json index f968994..6ff4303 100644 --- a/bootstrap/rpc/crds/blockfrostport.json +++ b/bootstrap/rpc/crds/blockfrostport.json @@ -1,5 +1,22 @@ { - "cost": {}, + "cost": { + "0": { + "minimum": 0, + "delta": 0 + }, + "1": { + "minimum": 0, + "delta": 0.0000006 + }, + "2": { + "minimum": 30, + "delta": 0.0000005 + }, + "3": { + "minimum": 80, + "delta": 0.0000004 + } + }, "options": [ { "description": "mainnet", diff --git a/bootstrap/rpc/crds/dbsyncport.json b/bootstrap/rpc/crds/dbsyncport.json index f641f7d..1c577d6 100644 --- a/bootstrap/rpc/crds/dbsyncport.json +++ b/bootstrap/rpc/crds/dbsyncport.json @@ -1,5 +1,22 @@ { - "cost": {}, + "cost": { + "0": { + "minimum": 0, + "delta": 0 + }, + "1": { + "minimum": 0, + "delta": 0.000007407 + }, + "2": { + "minimum": 100, + "delta": 0.000006713 + }, + "3": { + "minimum": 300, + "delta": 0.000005556 + } + }, "options": [ { "description": "mainnet", diff --git a/bootstrap/rpc/crds/kupoport.json b/bootstrap/rpc/crds/kupoport.json index 503aa31..ef2165c 100644 --- a/bootstrap/rpc/crds/kupoport.json +++ b/bootstrap/rpc/crds/kupoport.json @@ -1,5 +1,22 @@ { - "cost": {}, + "cost": { + "0": { + "minimum": 0, + "delta": 0 + }, + "1": { + "minimum": 0, + "delta": 0.0000009 + }, + "2": { + "minimum": 50, + "delta": 0.0000008 + }, + "3": { + "minimum": 150, + "delta": 0.0000007 + } + }, "options": [ { "description": "mainnet", diff --git a/bootstrap/rpc/crds/ogmiosport.json b/bootstrap/rpc/crds/ogmiosport.json index 72ea542..01e7f49 100644 --- a/bootstrap/rpc/crds/ogmiosport.json +++ b/bootstrap/rpc/crds/ogmiosport.json @@ -1,5 +1,22 @@ { - "cost": {}, + "cost": { + "0": { + "minimum": 0, + "delta": 0 + }, + "1": { + "minimum": 0, + "delta": 0.000002662 + }, + "2": { + "minimum": 100, + "delta": 0.000002315 + }, + "3": { + "minimum": 200, + "delta": 0.000001968 + } + }, "options": [ { "description": "mainnet", diff --git a/bootstrap/rpc/crds/submitapiport.json b/bootstrap/rpc/crds/submitapiport.json index 76077a6..c4031ff 100644 --- a/bootstrap/rpc/crds/submitapiport.json +++ b/bootstrap/rpc/crds/submitapiport.json @@ -1,5 +1,22 @@ { - "cost": {}, + "cost": { + "0": { + "minimum": 0, + "delta": 0 + }, + "1": { + "minimum": 0, + "delta": 0.000004 + }, + "2": { + "minimum": 200, + "delta": 0.0000036 + }, + "3": { + "minimum": 400, + "delta": 0.000003 + } + }, "options": [ { "description": "mainnet", From 9b747e990c2db606b805b41af90e6f3269a08e07 Mon Sep 17 00:00:00 2001 From: paulobressan Date: Thu, 17 Oct 2024 11:32:34 -0300 Subject: [PATCH 7/8] chore: updated test manifest --- test/fabric.manifest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fabric.manifest.yaml b/test/fabric.manifest.yaml index 0c4f171..0721a29 100644 --- a/test/fabric.manifest.yaml +++ b/test/fabric.manifest.yaml @@ -147,7 +147,7 @@ metadata: namespace: demeter-rpc data: cardanonodeport.json: | - {"options":[{"description":"mainnet - stable (9.1.1)","spec":{"network":"mainnet","throughputTier":"0","version":"9.1.1"}},{"description":"preprod - stable (9.1.1)","spec":{"network":"preprod","throughputTier":"0","version":"9.1.1"}},{"description":"preview - stable (9.1.1)","spec":{"network":"preview","throughputTier":"0","version":"9.1.1"}},{"description":"vector-testnet - stable (8.7.3)","spec":{"network":"vector-testnet","throughputTier":"0","version":"8.7.3"}},{"description":"prime-testnet - stable (8.7.3)","spec":{"network":"prime-testnet","throughputTier":"0","version":"8.7.3"}}],"crd":{"apiVersion":"apiextensions.k8s.io/v1","kind":"CustomResourceDefinition","metadata":{"name":"cardanonodeports.demeter.run"},"spec":{"group":"demeter.run","names":{"categories":["demeter-port"],"kind":"CardanoNodePort","plural":"cardanonodeports","shortNames":["cnpts"],"singular":"cardanonodeport"},"scope":"Namespaced","versions":[{"additionalPrinterColumns":[{"jsonPath":".spec.network","name":"Network","type":"string"},{"jsonPath":".spec.version","name":"Version","type":"string"},{"jsonPath":".spec.throughputTier","name":"Throughput Tier","type":"string"},{"jsonPath":".status.authenticatedEndpointUrl","name":"Authenticated Endpoint URL","type":"string"},{"jsonPath":".status.authToken","name":"Auth Token","type":"string"}],"name":"v1alpha1","schema":{"openAPIV3Schema":{"description":"Auto-generated derived type for CardanoNodePortSpec via `CustomResource`","properties":{"spec":{"properties":{"authToken":{"nullable":true,"type":"string"},"network":{"type":"string"},"throughputTier":{"type":"string"},"version":{"type":"string"}},"required":["network","throughputTier","version"],"type":"object"},"status":{"nullable":true,"properties":{"authToken":{"type":"string"},"authenticatedEndpointUrl":{"type":"string"}},"required":["authToken","authenticatedEndpointUrl"],"type":"object"}},"required":["spec"],"title":"CardanoNodePort","type":"object"}},"served":true,"storage":true,"subresources":{"status":{}}}]}}} + {"cost":{"0":{"minimum":200,"delta":0.3}},"options":[{"description":"mainnet - stable (9.1.1)","spec":{"network":"mainnet","version":"stable","throughputTier":"0"}},{"description":"preprod - stable (9.1.1)","spec":{"network":"preprod","version":"stable","throughputTier":"0"}},{"description":"preview - stable (9.1.1)","spec":{"network":"preview","version":"stable","throughputTier":"0"}},{"description":"vector-testnet - stable (8.7.3)","spec":{"network":"vector-testnet","version":"stable","throughputTier":"0"}},{"description":"prime-testnet - stable (8.7.3)","spec":{"network":"prime-testnet","version":"stable","throughputTier":"0"}}],"crd":{"apiVersion":"apiextensions.k8s.io/v1","kind":"CustomResourceDefinition","metadata":{"name":"cardanonodeports.demeter.run"},"spec":{"group":"demeter.run","names":{"categories":["demeter-port"],"kind":"CardanoNodePort","plural":"cardanonodeports","shortNames":["cnpts"],"singular":"cardanonodeport"},"scope":"Namespaced","versions":[{"additionalPrinterColumns":[{"jsonPath":".spec.network","name":"Network","type":"string"},{"jsonPath":".spec.version","name":"Version","type":"string"},{"jsonPath":".spec.throughputTier","name":"Throughput Tier","type":"string"},{"jsonPath":".status.authenticatedEndpointUrl","name":"Authenticated Endpoint URL","type":"string"},{"jsonPath":".status.authToken","name":"Auth Token","type":"string"}],"name":"v1alpha1","schema":{"openAPIV3Schema":{"description":"Auto-generated derived type for CardanoNodePortSpec via `CustomResource`","properties":{"spec":{"properties":{"authToken":{"nullable":true,"type":"string"},"network":{"type":"string"},"throughputTier":{"type":"string"},"version":{"type":"string"}},"required":["network","throughputTier","version"],"type":"object"},"status":{"nullable":true,"properties":{"authToken":{"type":"string"},"authenticatedEndpointUrl":{"type":"string"}},"required":["authToken","authenticatedEndpointUrl"],"type":"object"}},"required":["spec"],"title":"CardanoNodePort","type":"object"}},"served":true,"storage":true,"subresources":{"status":{}}}]}}} --- apiVersion: apps/v1 kind: Deployment From c263023e9b5d23599bf7be0dfe890a990e8880dd Mon Sep 17 00:00:00 2001 From: paulobressan Date: Thu, 17 Oct 2024 14:36:46 -0300 Subject: [PATCH 8/8] feat: improved usage calculation --- src/domain/metadata/mod.rs | 2 +- src/domain/usage/mod.rs | 18 +++++++++++------- src/drivers/billing/mod.rs | 8 ++++---- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/domain/metadata/mod.rs b/src/domain/metadata/mod.rs index cb4b893..84b5af7 100644 --- a/src/domain/metadata/mod.rs +++ b/src/domain/metadata/mod.rs @@ -34,7 +34,7 @@ impl FromStr for KnownField { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceMetadataCost { - pub minimum: i64, + pub minimum: f64, pub delta: f64, } diff --git a/src/domain/usage/mod.rs b/src/domain/usage/mod.rs index 5536408..af46c3b 100644 --- a/src/domain/usage/mod.rs +++ b/src/domain/usage/mod.rs @@ -82,20 +82,24 @@ impl UsageReportImpl for Vec { }; let first_day = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1).unwrap(); let days = (next_month - first_day).num_days(); - let max_interval = days * 24 * 60 * 60; + let month_interval = (days * 24 * 60 * 60) as f64; self.iter_mut().for_each(|usage| { match metadata.find_by_kind(&usage.resource_kind) { Ok(metadata) => match metadata { Some(metadata) => match metadata.cost.get(&usage.tier) { Some(cost) => { - usage.units_cost = Some((usage.units as f64) * cost.delta); + let value = (usage.units as f64) * cost.delta; + let rounded = (value * 100.0).round() / 100.0; - if cost.minimum > 0 { - usage.minimum_cost = Some( - ((cost.minimum as f64) / (max_interval as f64)) - * (usage.interval as f64), - ); + usage.units_cost = Some(rounded); + + if cost.minimum > 0. { + let value = + (cost.minimum / month_interval) * (usage.interval as f64); + let rounded = (value * 100.0).round() / 100.0; + + usage.minimum_cost = Some(rounded); } } None => { diff --git a/src/drivers/billing/mod.rs b/src/drivers/billing/mod.rs index 81b27d4..5fd322a 100644 --- a/src/drivers/billing/mod.rs +++ b/src/drivers/billing/mod.rs @@ -88,8 +88,8 @@ fn csv(report: Vec, period: &str) { &r.tier, &format!("{:.1}h", ((r.interval as f64) / 60.) / 60.), &r.units.to_string(), - &format!("${:.3}", r.units_cost.unwrap_or(0.)), - &format!("${:.3}", r.minimum_cost.unwrap_or(0.)), + &format!("${:.2}", r.units_cost.unwrap_or(0.)), + &format!("${:.2}", r.minimum_cost.unwrap_or(0.)), ]); if let Err(error) = result { error!(?error); @@ -153,8 +153,8 @@ fn table(report: Vec) { &r.tier, &format!("{:.1}h", ((r.interval as f64) / 60.) / 60.), &r.units.to_string(), - &format!("${:.3}", r.units_cost.unwrap_or(0.)), - &format!("${:.3}", r.minimum_cost.unwrap_or(0.)), + &format!("${:.2}", r.units_cost.unwrap_or(0.)), + &format!("${:.2}", r.minimum_cost.unwrap_or(0.)), ]); }