-
Multi-Cloud Cost Management Made Simple
-
Securely visualize and analyze costs across AWS, Alibaba Cloud, and more. All data stays on your machine with encryption.
-
-
-
🪟 Windows
-
🍎 macOS
+
+
+
+
+
+
Multi-Cloud Cost Management Made Simple
+
Securely visualize and analyze costs across AWS, Alibaba Cloud, and DeepSeek. All data stays on your machine, encrypted.
+
+
+ 🪟 Windows
+ 🍎 macOS (Intel & Apple Silicon)
+
-
-
+
-
-
-
-
-
-
-
-
+
+
+
+
-
-
+
-
-
-
-
-
-
-
🌐
-
Multi-Cloud Support
-
Full support for AWS and Alibaba Cloud. Azure and GCP coming soon. Manage all your cloud costs in one place.
-
-
-
📊
-
Cost Visualization
-
Beautiful charts and graphs show monthly overviews, per-service breakdowns, and 30-day cost trends at a glance.
-
-
-
🔒
-
Security First
-
AES-256-GCM encryption for all credentials. Your data never leaves your machine. No cloud sync, no telemetry.
-
-
-
⚡
-
Smart Caching
-
Intelligent 6-hour cache minimizes API calls and costs. Auto-refresh when stale, manual refresh when needed.
-
-
-
🎨
-
Modern UI
-
Built with GPUI, Zed's GPU-accelerated framework. Native performance with a sleek dark theme interface.
-
-
-
💰
-
Cost Analytics
-
Track daily statistics including total, average, max, and min costs. Month-over-month comparisons built in.
+
+
-
+
-
-
-
-
-
-
-
1
-
Download & Install
-
Download CloudBridge for your platform. No installation required - just run the executable.
-
-
-
2
-
Add Cloud Accounts
-
Enter your cloud provider credentials. They're encrypted with AES-256-GCM and stored locally on your machine.
+
+
+
+
-
-
3
-
Visualize Costs
-
View your cost data with beautiful charts. Data is cached intelligently to minimize API costs.
+
+
+
🌐
+
Multi-Cloud Support
+
Full support for AWS, Alibaba Cloud, and DeepSeek balance tracking. Azure and GCP coming soon. Manage all your cloud costs in one place.
+
+
+
📊
+
Cost Visualization
+
Beautiful charts and graphs show monthly overviews, per-service breakdowns, and 30-day cost trends at a glance.
+
+
+
🔒
+
Security First
+
AES-256-GCM encryption for all credentials. Your data never leaves your machine. No cloud sync, no telemetry.
+
+
+
⚡
+
Smart Caching
+
Intelligent 6-hour cache minimizes API calls and costs. Auto-refresh when stale, manual refresh when needed.
+
+
+
🎨
+
Modern UI
+
Built with GPUI, Zed's GPU-accelerated framework. Native performance with a sleek dark theme interface.
+
+
+
💰
+
Cost Analytics
+
Track daily statistics including total, average, max, and min costs. Month-over-month comparisons built in.
+
-
-
-
-
-
-
-
Ready to Get Started?
-
Download CloudBridge for your platform and start managing your cloud costs today.
-
-
Or view all releases on GitHub
-
-
+
-
-
-
-
-
-
-
Is CloudBridge free to use?
-
Yes! CloudBridge is completely free and open source under the MIT license. You only pay for your cloud provider's API costs (AWS Cost Explorer charges $0.01 per request, Alibaba Cloud billing API is free).
+
+
+
+
-
-
How is my data secured?
-
All credentials are encrypted using AES-256-GCM before being stored locally. The encryption key is generated on your machine and stored in your local config file. Your data never leaves your computer except for direct API calls to your cloud providers.
+
+
+
1
+
Download & Install
+
Download CloudBridge for your platform. No installation required - just run the executable.
+
+
+
2
+
Add Cloud Accounts
+
Enter your cloud provider credentials. They're encrypted with AES-256-GCM and stored locally on your machine.
+
+
+
3
+
Visualize Costs
+
View your cost data with beautiful charts. Data is cached intelligently to minimize API costs.
+
-
-
Which cloud providers are supported?
-
Currently, AWS and Alibaba Cloud are fully supported. Azure and Google Cloud Platform support is planned for future releases. Check our roadmap on GitHub for the latest updates.
+
+
+
+
+
+
+
Ready to Get Started?
+
Download CloudBridge for your platform and start managing your cloud costs today.
+
-
-
Does CloudBridge work on Linux?
-
Native Linux builds are planned for future releases. Currently, CloudBridge supports Windows and macOS (Apple Silicon only).
+
+
Windows: SmartScreen may warn about unsigned executables. Click "More info" → "Run anyway" to proceed.
+
macOS: Unzip first, then run chmod +x cloudbridge-macos-* and launch from Terminal. If Gatekeeper blocks it, run xattr -cr cloudbridge-macos-* to remove quarantine flags.
+
Or browse all releases on GitHub.
-
-
How often is cost data refreshed?
-
CloudBridge uses intelligent caching with a 6-hour default refresh interval to minimize API costs. You can manually refresh data anytime by clicking the Refresh button.
+
+
+
+
+
+
+
-
-
Can I export my cost data?
-
Export functionality (CSV, PDF) is planned for version 0.3.0. For now, all cost data is stored in a local DuckDB database that you can query directly if needed.
+
+
+ Is CloudBridge free to use?
+ Yes! CloudBridge is completely free and open source under the MIT license. You only pay for your cloud provider's API costs (AWS Cost Explorer charges $0.01 per request; Alibaba Cloud and DeepSeek APIs are free).
+
+
+ How is my data secured?
+ All credentials are encrypted using AES-256-GCM before being stored locally. The encryption key is generated on your machine and stored in your local config file. Your data never leaves your computer except for direct API calls to your cloud providers.
+
+
+ Which cloud providers are supported?
+ AWS, Alibaba Cloud, and DeepSeek (balance tracking) are fully supported today. Azure and Google Cloud Platform support is planned for future releases. Check our roadmap on GitHub for the latest updates.
+
+
+ What IAM permissions does CloudBridge need?
+ CloudBridge only needs read-only billing access. For AWS, attach a policy with the four Cost Explorer read actions; for Alibaba Cloud, attach the built-in
AliyunBSSReadOnlyAccess policy. Ready-to-use templates and step-by-step instructions are in
docs/policies.md.
+
+
+ Does CloudBridge work on Linux?
+ Native Linux builds are planned for future releases. Currently, CloudBridge supports Windows (x64) and macOS (Intel and Apple Silicon).
+
+
+ How often is cost data refreshed?
+ CloudBridge uses intelligent caching with a 6-hour default refresh interval to minimize API costs. You can manually refresh data anytime by clicking the Refresh button.
+
+
+ Can I export my cost data?
+ Export functionality (CSV, PDF) is planned for version 0.3.0. For now, all cost data is stored in a local DuckDB database that you can query directly if needed.
+
-
-
+
+
diff --git a/src/cloud/aliyun.rs b/src/cloud/aliyun.rs
index 49ade06..3897c54 100644
--- a/src/cloud/aliyun.rs
+++ b/src/cloud/aliyun.rs
@@ -7,7 +7,7 @@ use serde::Deserialize;
use sha1::Sha1;
use std::collections::BTreeMap;
-use super::{CloudProvider, CloudService, CostData, CostSummary, ServiceCost};
+use super::{CloudService, CostData, CostSummary, ServiceCost, SourceId};
type HmacSha1 = Hmac
;
@@ -287,7 +287,7 @@ impl CloudService for AliyunCloudService {
Ok(CostSummary {
account_id: self.account_id.clone(),
account_name: self.account_name.clone(),
- provider: CloudProvider::Aliyun,
+ source_id: SourceId::from("Aliyun"),
current_month_cost,
last_month_cost,
currency: "CNY".to_string(),
diff --git a/src/cloud/aws.rs b/src/cloud/aws.rs
index 16e311f..59cff4f 100644
--- a/src/cloud/aws.rs
+++ b/src/cloud/aws.rs
@@ -6,7 +6,7 @@ use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::{Digest, Sha256};
-use super::{CloudProvider, CloudService, CostData, CostSummary};
+use super::{CloudService, CostData, CostSummary, SourceId};
type HmacSha256 = Hmac;
@@ -664,7 +664,7 @@ impl CloudService for AwsCloudService {
Ok(CostSummary {
account_id: self.account_id.clone(),
account_name: self.account_name.clone(),
- provider: CloudProvider::AWS,
+ source_id: SourceId::from("AWS"),
current_month_cost,
last_month_cost,
currency,
diff --git a/src/cloud/deepseek.rs b/src/cloud/deepseek.rs
index ef1858d..ce1965e 100644
--- a/src/cloud/deepseek.rs
+++ b/src/cloud/deepseek.rs
@@ -3,7 +3,7 @@
use anyhow::{anyhow, Result};
use serde::Deserialize;
-use super::{CloudProvider, CloudService, CostData, CostSummary, CostTrend, ServiceCost};
+use super::{CloudService, CostData, CostSummary, CostTrend, ServiceCost, SourceId};
/// DeepSeek balance info
#[derive(Debug, Deserialize)]
@@ -125,7 +125,7 @@ impl CloudService for DeepSeekService {
Ok(CostSummary {
account_id: self.account_id.clone(),
account_name: self.account_name.clone(),
- provider: CloudProvider::DeepSeek,
+ source_id: SourceId::from("DeepSeek"),
current_month_cost: total,
last_month_cost: 0.0,
currency: balance_info.currency.clone(),
diff --git a/src/cloud/mod.rs b/src/cloud/mod.rs
index 64fe9d4..a31fa38 100644
--- a/src/cloud/mod.rs
+++ b/src/cloud/mod.rs
@@ -1,48 +1,30 @@
-//! Cloud provider module
+//! Billing sources: accounts, the data they report, and the client trait.
pub mod aliyun;
pub mod aws;
pub mod deepseek;
+pub mod registry;
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
-/// Cloud provider type
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
-#[allow(clippy::upper_case_acronyms)]
-pub enum CloudProvider {
- #[default]
- AWS,
- Aliyun,
- Azure,
- GCP,
- DeepSeek,
-}
+pub use registry::{SourceDescriptor, SourceId};
-impl CloudProvider {
- /// Get full name of cloud provider
- #[allow(dead_code)]
- pub fn display_name(&self) -> &'static str {
- match self {
- CloudProvider::AWS => "Amazon Web Services",
- CloudProvider::Aliyun => "Alibaba Cloud",
- CloudProvider::Azure => "Microsoft Azure",
- CloudProvider::GCP => "Google Cloud Platform",
- CloudProvider::DeepSeek => "DeepSeek",
- }
- }
+/// Shown in place of a source's name when its id is not in the registry.
+/// Accounts like that are filtered out on load, so this is a backstop.
+const UNKNOWN_SOURCE: &str = "Unknown";
- /// Get short name of cloud provider
- pub fn short_name(&self) -> &'static str {
- match self {
- CloudProvider::AWS => "AWS",
- CloudProvider::Aliyun => "Aliyun",
- CloudProvider::Azure => "Azure",
- CloudProvider::GCP => "GCP",
- CloudProvider::DeepSeek => "DeepSeek",
- }
- }
+/// The credentials and identity a [`CloudService`] is built from.
+///
+/// Bundled into one struct so [`SourceDescriptor::build`] can be a plain
+/// function pointer.
+pub struct SourceContext {
+ pub account_id: String,
+ pub account_name: String,
+ pub access_key_id: String,
+ pub secret_access_key: String,
+ pub region: Option,
}
/// Cloud account information
@@ -52,8 +34,8 @@ pub struct CloudAccount {
pub id: String,
/// Account name (user-defined)
pub name: String,
- /// Cloud provider
- pub provider: CloudProvider,
+ /// Billing source this account belongs to; see [`registry`].
+ pub source_id: SourceId,
/// Access Key ID (encrypted storage)
pub access_key_id: String,
/// Secret Access Key (encrypted storage)
@@ -68,6 +50,31 @@ pub struct CloudAccount {
pub enabled: bool,
}
+impl CloudAccount {
+ /// The descriptor for this account's source, or `None` if the stored id
+ /// is not registered in this build.
+ pub fn descriptor(&self) -> Option<&'static SourceDescriptor> {
+ self.source_id.descriptor()
+ }
+
+ /// Short label for the source, for badges and log lines.
+ pub fn short_name(&self) -> &'static str {
+ self.descriptor().map_or(UNKNOWN_SOURCE, |s| s.short_name)
+ }
+
+ /// Credentials in the shape [`SourceDescriptor::build`] expects, with the
+ /// source's default region filled in when the account stored none.
+ pub fn context(&self, descriptor: &SourceDescriptor) -> SourceContext {
+ SourceContext {
+ account_id: self.id.clone(),
+ account_name: self.name.clone(),
+ access_key_id: self.access_key_id.clone(),
+ secret_access_key: self.secret_access_key.clone(),
+ region: descriptor.region_or_default(self.region.clone()),
+ }
+ }
+}
+
/// Cost data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostData {
@@ -90,8 +97,8 @@ pub struct CostSummary {
pub account_id: String,
/// Account name
pub account_name: String,
- /// Cloud provider
- pub provider: CloudProvider,
+ /// Billing source this summary came from
+ pub source_id: SourceId,
/// Current month cost
pub current_month_cost: f64,
/// Last month cost
@@ -106,6 +113,24 @@ pub struct CostSummary {
pub last_month_details: Vec,
}
+impl CostSummary {
+ fn descriptor(&self) -> Option<&'static SourceDescriptor> {
+ self.source_id.descriptor()
+ }
+
+ /// Short label for the source, for badges.
+ pub fn short_name(&self) -> &'static str {
+ self.descriptor().map_or(UNKNOWN_SOURCE, |s| s.short_name)
+ }
+
+ /// Whether this is a point-in-time balance rather than a period cost.
+ /// The dashboard lists the two kinds in separate sections and labels
+ /// their amounts differently.
+ pub fn is_snapshot(&self) -> bool {
+ self.descriptor().is_some_and(SourceDescriptor::is_snapshot)
+ }
+}
+
/// Service cost detail
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceCost {
diff --git a/src/cloud/registry.rs b/src/cloud/registry.rs
new file mode 100644
index 0000000..e6c7449
--- /dev/null
+++ b/src/cloud/registry.rs
@@ -0,0 +1,250 @@
+//! Billing source registry.
+//!
+//! A source is a row in [`SOURCES`], not an enum variant. Adding one means
+//! adding a [`SourceDescriptor`] and a parser — nothing else in the codebase
+//! learns its name. That matters because the roadmap adds model-provider
+//! APIs, token plans and local agent usage on top of the public clouds, and
+//! the previous `CloudProvider` enum had to be matched in 48 places.
+//!
+//! Callers ask the descriptor what a source can do rather than who it is:
+//! DeepSeek renders as a balance because its [`Reporting`] is
+//! [`Reporting::Snapshot`], not because it is called DeepSeek.
+
+use serde::{Deserialize, Serialize};
+
+use super::{aliyun::AliyunCloudService, aws::AwsCloudService, deepseek::DeepSeekService};
+use super::{CloudService, SourceContext};
+
+/// What a source reports, and therefore how it can be displayed.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Reporting {
+ /// Cost accrued over a period. `trend_window_days` is how far back a
+ /// daily trend is worth requesting — Alibaba Cloud needs one API call
+ /// per day, so it gets a shorter window than AWS.
+ Periodic { trend_window_days: i64 },
+ /// A point-in-time balance. There is no period cost and no history to
+ /// chart.
+ Snapshot,
+}
+
+/// Identifier of a billing source.
+///
+/// Persisted verbatim in the `cloud_accounts` table, so these strings are
+/// part of the on-disk format and must not be renamed without a migration.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct SourceId(String);
+
+impl SourceId {
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+
+ /// The descriptor for this id, or `None` if no source is registered
+ /// under it — an account written by a newer build, or by a build that
+ /// still had the Azure and GCP enum variants.
+ pub fn descriptor(&self) -> Option<&'static SourceDescriptor> {
+ get(&self.0)
+ }
+}
+
+impl From<&str> for SourceId {
+ fn from(id: &str) -> Self {
+ Self(id.to_string())
+ }
+}
+
+impl From for SourceId {
+ fn from(id: String) -> Self {
+ Self(id)
+ }
+}
+
+/// Everything the application needs to know about a billing source.
+pub struct SourceDescriptor {
+ /// Stable identifier; see [`SourceId`].
+ pub id: &'static str,
+ pub display_name: &'static str,
+ pub short_name: &'static str,
+ /// Label for the public half of the credential.
+ pub access_key_label: &'static str,
+ /// Label for the secret half, or `None` when the source authenticates
+ /// with a single key.
+ pub secret_key_label: Option<&'static str>,
+ /// Region applied when the user leaves the field blank, or `None` when
+ /// the source has no notion of a region.
+ pub default_region: Option<&'static str>,
+ pub reporting: Reporting,
+ /// Builds the client. A function pointer keeps construction in this
+ /// table instead of a `match` in every caller.
+ pub build: fn(SourceContext) -> Box,
+}
+
+impl SourceDescriptor {
+ pub fn source_id(&self) -> SourceId {
+ SourceId::from(self.id)
+ }
+
+ /// Whether the credential form should require a secret key.
+ pub fn needs_secret_key(&self) -> bool {
+ self.secret_key_label.is_some()
+ }
+
+ pub fn secret_key_placeholder(&self) -> &'static str {
+ self.secret_key_label
+ .unwrap_or("(Not required, leave empty)")
+ }
+
+ pub fn region_placeholder(&self) -> String {
+ match self.default_region {
+ Some(region) => format!("Region (optional, default {})", region),
+ None => "(Not required)".to_string(),
+ }
+ }
+
+ /// Region to use for an account that stored none.
+ pub fn region_or_default(&self, region: Option) -> Option {
+ region.or_else(|| self.default_region.map(str::to_string))
+ }
+
+ /// Days of daily trend worth requesting, or `None` for a source that has
+ /// no history to chart.
+ pub fn trend_window_days(&self) -> Option {
+ match self.reporting {
+ Reporting::Periodic { trend_window_days } => Some(trend_window_days),
+ Reporting::Snapshot => None,
+ }
+ }
+
+ /// Whether this source reports a balance rather than a period cost.
+ pub fn is_snapshot(&self) -> bool {
+ matches!(self.reporting, Reporting::Snapshot)
+ }
+}
+
+static SOURCES: &[SourceDescriptor] = &[
+ SourceDescriptor {
+ id: "AWS",
+ display_name: "Amazon Web Services",
+ short_name: "AWS",
+ access_key_label: "Access Key ID",
+ secret_key_label: Some("Secret Access Key"),
+ default_region: Some("us-east-1"),
+ reporting: Reporting::Periodic {
+ trend_window_days: 30,
+ },
+ build: |ctx| {
+ Box::new(AwsCloudService::new(
+ ctx.account_id,
+ ctx.account_name,
+ ctx.access_key_id,
+ ctx.secret_access_key,
+ ctx.region,
+ ))
+ },
+ },
+ SourceDescriptor {
+ id: "Aliyun",
+ display_name: "Alibaba Cloud",
+ short_name: "Aliyun",
+ access_key_label: "AccessKey ID",
+ secret_key_label: Some("AccessKey Secret"),
+ default_region: Some("cn-hangzhou"),
+ // One API call per day, so a shorter window than AWS.
+ reporting: Reporting::Periodic {
+ trend_window_days: 7,
+ },
+ build: |ctx| {
+ Box::new(AliyunCloudService::new(
+ ctx.account_id,
+ ctx.account_name,
+ ctx.access_key_id,
+ ctx.secret_access_key,
+ ctx.region,
+ ))
+ },
+ },
+ SourceDescriptor {
+ id: "DeepSeek",
+ display_name: "DeepSeek",
+ short_name: "DeepSeek",
+ access_key_label: "API Key",
+ secret_key_label: None,
+ default_region: None,
+ reporting: Reporting::Snapshot,
+ build: |ctx| {
+ Box::new(DeepSeekService::new(
+ ctx.account_id,
+ ctx.account_name,
+ ctx.access_key_id,
+ ctx.secret_access_key,
+ ctx.region,
+ ))
+ },
+ },
+];
+
+/// Every registered source, in the order they are offered in the UI.
+pub fn all() -> &'static [SourceDescriptor] {
+ SOURCES
+}
+
+/// The descriptor registered under `id`, if any.
+pub fn get(id: &str) -> Option<&'static SourceDescriptor> {
+ SOURCES.iter().find(|source| source.id == id)
+}
+
+/// The source offered first when adding an account.
+pub fn default_source() -> &'static SourceDescriptor {
+ &SOURCES[0]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Ids are persisted in `cloud_accounts`, so a duplicate would make one of
+ /// the two sources unreachable and silently reroute existing accounts.
+ #[test]
+ fn ids_are_unique() {
+ let mut seen = Vec::new();
+ for source in all() {
+ assert!(
+ !seen.contains(&source.id),
+ "duplicate source id {}",
+ source.id
+ );
+ seen.push(source.id);
+ }
+ }
+
+ /// The round trip an account takes: descriptor -> stored id -> descriptor.
+ #[test]
+ fn every_descriptor_resolves_from_its_own_id() {
+ for source in all() {
+ let resolved = source
+ .source_id()
+ .descriptor()
+ .unwrap_or_else(|| panic!("{} does not resolve", source.id));
+ assert_eq!(resolved.id, source.id);
+ }
+ }
+
+ /// A source with no region must not offer one, or the credential form
+ /// would ask for a value that is silently discarded.
+ #[test]
+ fn region_placeholder_matches_default_region() {
+ for source in all() {
+ match source.default_region {
+ Some(region) => {
+ assert!(
+ source.region_placeholder().contains(region),
+ "{}",
+ source.id
+ )
+ }
+ None => assert_eq!(source.region_placeholder(), "(Not required)"),
+ }
+ }
+ }
+}
diff --git a/src/db.rs b/src/db.rs
index 8a351b0..c84ba14 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -6,8 +6,8 @@ use duckdb::{params, Connection};
use std::sync::{Arc, Mutex};
use crate::cloud::{
- BudgetInfo, BudgetStatus, CloudAccount, CloudProvider, CostData, CostSummary, CostTrend,
- DailyCost, ServiceCost,
+ BudgetInfo, BudgetStatus, CloudAccount, CostData, CostSummary, CostTrend, DailyCost,
+ ServiceCost, SourceId,
};
use crate::config::get_database_path;
use crate::crypto::get_crypto_manager;
@@ -31,6 +31,8 @@ pub fn init_database() -> Result<()> {
CREATE TABLE IF NOT EXISTS cloud_accounts (
id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL,
+ -- Holds a registry SourceId. Column name predates the registry;
+ -- PR2 rebuilds this schema, so it is not worth a migration now.
provider VARCHAR NOT NULL,
access_key_id VARCHAR NOT NULL,
secret_access_key VARCHAR NOT NULL,
@@ -155,7 +157,7 @@ pub fn save_account(account: &CloudAccount) -> Result<()> {
params![
account.id,
account.name,
- format!("{:?}", account.provider),
+ account.source_id.as_str(),
"",
"",
account.region,
@@ -180,15 +182,7 @@ pub fn get_all_accounts() -> Result> {
let accounts = stmt
.query_map([], |row| {
- let provider_str: String = row.get(2)?;
- let provider = match provider_str.as_str() {
- "AWS" => CloudProvider::AWS,
- "Aliyun" => CloudProvider::Aliyun,
- "Azure" => CloudProvider::Azure,
- "GCP" => CloudProvider::GCP,
- "DeepSeek" => CloudProvider::DeepSeek,
- _ => CloudProvider::AWS,
- };
+ let source_id = SourceId::from(row.get::<_, String>(2)?);
let encrypted_ak: String = row.get(3)?;
let encrypted_sk: String = row.get(4)?;
@@ -199,7 +193,7 @@ pub fn get_all_accounts() -> Result> {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
- provider,
+ source_id,
encrypted_ak,
encrypted_sk,
row.get::<_, Option>(5)?,
@@ -214,7 +208,7 @@ pub fn get_all_accounts() -> Result> {
for (
id,
name,
- provider,
+ source_id,
encrypted_ak,
encrypted_sk,
region,
@@ -223,6 +217,20 @@ pub fn get_all_accounts() -> Result> {
enabled,
) in accounts
{
+ // An id with no descriptor comes from a build that knew a source this
+ // one does not. Skip the row rather than guessing: silently reading it
+ // as some other provider would sign requests with the wrong scheme and
+ // file the resulting costs under the wrong source.
+ if source_id.descriptor().is_none() {
+ tracing::warn!(
+ "Skipping account {} ({}): no billing source registered under '{}'",
+ name,
+ id,
+ source_id.as_str()
+ );
+ continue;
+ }
+
// Try to load secrets from OS keyring first (migration path). If not present, fall back to
// decrypting existing values from DB and migrate them into keyring.
let (access_key_id, secret_access_key) = match secret_store::get_account_secrets(&id)? {
@@ -257,7 +265,7 @@ pub fn get_all_accounts() -> Result> {
result.push(CloudAccount {
id,
name,
- provider,
+ source_id,
access_key_id,
secret_access_key,
region,
@@ -359,7 +367,7 @@ pub fn get_all_cost_summaries() -> Result> {
summaries.push(CostSummary {
account_id: account.id,
account_name: account.name,
- provider: account.provider,
+ source_id: account.source_id,
current_month_cost: 0.0,
last_month_cost: 0.0,
currency: "USD".to_string(),
@@ -375,11 +383,11 @@ pub fn get_all_cost_summaries() -> Result> {
// ==================== Cache Functions ====================
/// Check if cost summary cache is valid
-/// account_name and provider are passed by the caller to avoid deadlock when acquiring lock while holding database lock
+/// account_name and source_id are passed by the caller to avoid deadlock when acquiring lock while holding database lock
pub fn get_cached_cost_summary_with_account(
account_id: &str,
account_name: &str,
- provider: &CloudProvider,
+ source_id: &SourceId,
) -> Result