Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 62 additions & 16 deletions pkg/plugins/mongodb-atlas/cmd/main/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
"github.com/opencost/opencost/pkg/currency"
"io"
"net/http"
"time"
Expand Down Expand Up @@ -31,7 +32,10 @@ var handshakeConfig = plugin.HandshakeConfig{
MagicCookieValue: "mongodb-atlas",
}

const costExplorerPendingInvoicesURL = "https://cloud.mongodb.com/api/atlas/v2/orgs/%s/invoices/pending"
const (
costExplorerPendingInvoicesURL = "https://cloud.mongodb.com/api/atlas/v2/orgs/%s/invoices/pending"
defaultCurrency = "USD"
)

func main() {
log.Debug("Initializing Mongo plugin")
Expand All @@ -50,9 +54,25 @@ func main() {
// as per https://www.mongodb.com/docs/atlas/api/atlas-admin-api-ref/,
// atlas admin APIs have a limit of 100 requests per minute
rateLimiter := rate.NewLimiter(1.1, 2)

var currencyConverter currency.Converter
if atlasConfig.ExchangeAPIKey != "" && atlasConfig.TargetCurrency != defaultCurrency {
converter, err := currency.NewConverter(currency.Config{
APIKey: atlasConfig.ExchangeAPIKey,
})
if err != nil {
log.Warnf("Failed to initialize currency converter: %v. Will use %s.", err, defaultCurrency)
} else {
currencyConverter = converter
log.Infof("Currency converter initialized for target currency: %s", atlasConfig.TargetCurrency)
}
}

atlasCostSrc := AtlasCostSource{
rateLimiter: rateLimiter,
orgID: atlasConfig.OrgID,
rateLimiter: rateLimiter,
orgID: atlasConfig.OrgID,
targetCurrency: atlasConfig.TargetCurrency,
currencyConverter: currencyConverter,
}
atlasCostSrc.atlasClient = getAtlasClient(*atlasConfig)

Expand Down Expand Up @@ -80,9 +100,11 @@ func getAtlasClient(atlasConfig atlasconfig.AtlasConfig) HTTPClient {

// Implementation of CustomCostSource
type AtlasCostSource struct {
orgID string
rateLimiter *rate.Limiter
atlasClient HTTPClient
orgID string
rateLimiter *rate.Limiter
atlasClient HTTPClient
targetCurrency string
currencyConverter currency.Converter
}

type HTTPClient interface {
Expand All @@ -95,7 +117,7 @@ func validateRequest(req *pb.CustomCostRequest) []string {
// 1. Check if resolution is less than a day
if req.Resolution.AsDuration() < 24*time.Hour {
var resolutionMessage = "Resolution should be at least one day."
log.Warnf(resolutionMessage)
log.Warnf("%s", resolutionMessage)
errors = append(errors, resolutionMessage)
}
// Get the start of the current month
Expand All @@ -104,14 +126,14 @@ func validateRequest(req *pb.CustomCostRequest) []string {
// 2. Check if start time is before the start of the current month
if req.Start.AsTime().Before(currentMonthStart) {
var startDateMessage = "Start date cannot be before the current month. Historical costs not currently supported"
log.Warnf(startDateMessage)
log.Warnf("%s", startDateMessage)
errors = append(errors, startDateMessage)
}

// 3. Check if end time is before the start of the current month
if req.End.AsTime().Before(currentMonthStart) {
var endDateMessage = "End date cannot be before the current month. Historical costs not currently supported"
log.Warnf(endDateMessage)
log.Warnf("%s", endDateMessage)
errors = append(errors, endDateMessage)
}

Expand Down Expand Up @@ -217,16 +239,40 @@ func filterLineItemsByWindow(win *opencost.Window, lineItems []atlasplugin.LineI

func (a *AtlasCostSource) getAtlasCostsForWindow(win *opencost.Window, lineItems []atlasplugin.LineItem) *pb.CustomCostResponse {

//filter responses between the win start and win end dates

costsInWindow := filterLineItemsByWindow(win, lineItems)

if a.currencyConverter != nil && a.targetCurrency != defaultCurrency && a.targetCurrency != "" {
for _, cost := range costsInWindow {
if convertedBilled, err := a.currencyConverter.Convert(float64(cost.BilledCost), defaultCurrency, a.targetCurrency); err == nil {
cost.BilledCost = float32(convertedBilled)
} else {
log.Debugf("Failed to convert billed cost: %v", err)
}

if convertedList, err := a.currencyConverter.Convert(float64(cost.ListCost), defaultCurrency, a.targetCurrency); err == nil {
cost.ListCost = float32(convertedList)
} else {
log.Debugf("Failed to convert list cost: %v", err)
}

if convertedUnit, err := a.currencyConverter.Convert(float64(cost.ListUnitPrice), defaultCurrency, a.targetCurrency); err == nil {
cost.ListUnitPrice = float32(convertedUnit)
} else {
log.Debugf("Failed to convert unit price: %v", err)
}
}

if exchangeRate, err := a.currencyConverter.GetRate(defaultCurrency, a.targetCurrency); err == nil {
log.Debugf("Using exchange rate %s to %s: %f", defaultCurrency, a.targetCurrency, exchangeRate)
}
}

resp := pb.CustomCostResponse{
Metadata: map[string]string{"api_client_version": "v1"},
CostSource: "data_storage",
Domain: "mongodb-atlas",
Version: "v1",
Currency: "USD",
Currency: a.targetCurrency,
Start: timestamppb.New(*win.Start()),
End: timestamppb.New(*win.End()),
Errors: []string{},
Expand All @@ -244,8 +290,8 @@ func GetPendingInvoices(org string, client HTTPClient) ([]atlasplugin.LineItem,
response, error := client.Do(request)
if error != nil {
msg := fmt.Sprintf("getPending Invoices: error from server: %v", error)
log.Errorf(msg)
return nil, fmt.Errorf(msg)
log.Errorf("%s", msg)
return nil, fmt.Errorf("%s", msg)

}

Expand All @@ -256,8 +302,8 @@ func GetPendingInvoices(org string, client HTTPClient) ([]atlasplugin.LineItem,
respUnmarshalError := json.Unmarshal([]byte(body), &pendingInvoicesResponse)
if respUnmarshalError != nil {
msg := fmt.Sprintf("pendingInvoices: error unmarshalling response: %v", respUnmarshalError)
log.Errorf(msg)
return nil, fmt.Errorf(msg)
log.Errorf("%s", msg)
return nil, fmt.Errorf("%s", msg)
}

return pendingInvoicesResponse.LineItems, nil
Expand Down
120 changes: 120 additions & 0 deletions pkg/plugins/mongodb-atlas/cmd/main/main_currency_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package main

import (
"testing"
"time"

atlasplugin "github.com/opencost/opencost-plugins/pkg/plugins/mongodb-atlas/plugin"
"github.com/opencost/opencost/core/pkg/opencost"
"github.com/stretchr/testify/assert"
"golang.org/x/time/rate"
)

type MockConverter struct {
rate float64
}

func (m *MockConverter) Convert(amount float64, from, to string) (float64, error) {
if from == "USD" && to == "EUR" {
return amount * m.rate, nil
}
return amount, nil
}

func (m *MockConverter) GetRate(from, to string) (float64, error) {
if from == "USD" && to == "EUR" {
return m.rate, nil
}
return 1.0, nil
}

func (m *MockConverter) GetSupportedCurrencies() ([]string, error) {
return []string{"USD", "EUR", "GBP"}, nil
}

func TestAtlasCostSource_CurrencyConversion(t *testing.T) {
mockConverter := &MockConverter{rate: 0.85}

costSource := &AtlasCostSource{
orgID: "test-org",
rateLimiter: rate.NewLimiter(1.0, 1),
targetCurrency: "EUR",
currencyConverter: mockConverter,
}

start := time.Now().Add(-24 * time.Hour).UTC()
end := time.Now().UTC()
win := opencost.NewWindow(&start, &end)

// Create test line items with USD prices
// Note: StartDate and EndDate need to be within the window to be included
itemStart := start.Add(1 * time.Hour)
itemEnd := end.Add(-1 * time.Hour)
lineItems := []atlasplugin.LineItem{
{
ClusterName: "test-cluster",
GroupId: "test-group",
GroupName: "Test Group",
SKU: "TEST_SKU",
TotalPriceCents: 10000, // $100.00
UnitPriceDollars: 1.0,
Quantity: 100,
Unit: "hours",
StartDate: itemStart.Format(time.RFC3339),
EndDate: itemEnd.Format(time.RFC3339),
},
}

resp := costSource.getAtlasCostsForWindow(&win, lineItems)

assert.Equal(t, "EUR", resp.Currency)
assert.Len(t, resp.Costs, 1)

cost := resp.Costs[0]
expectedBilledCost := float32(100.0 * 0.85)
expectedListCost := float32(100.0 * 0.85)
expectedUnitPrice := float32(1.0 * 0.85)

assert.Equal(t, expectedBilledCost, cost.BilledCost)
assert.Equal(t, expectedListCost, cost.ListCost)
assert.Equal(t, expectedUnitPrice, cost.ListUnitPrice)
}

func TestAtlasCostSource_NoConversion(t *testing.T) {
costSource := &AtlasCostSource{
orgID: "test-org",
rateLimiter: rate.NewLimiter(1.0, 1),
targetCurrency: "USD",
}

start := time.Now().Add(-24 * time.Hour).UTC()
end := time.Now().UTC()
win := opencost.NewWindow(&start, &end)

itemStart := start.Add(1 * time.Hour)
itemEnd := end.Add(-1 * time.Hour)
lineItems := []atlasplugin.LineItem{
{
ClusterName: "test-cluster",
GroupId: "test-group",
GroupName: "Test Group",
SKU: "TEST_SKU",
TotalPriceCents: 10000, // $100.00
UnitPriceDollars: 1.0,
Quantity: 100,
Unit: "hours",
StartDate: itemStart.Format(time.RFC3339),
EndDate: itemEnd.Format(time.RFC3339),
},
}

resp := costSource.getAtlasCostsForWindow(&win, lineItems)

assert.Equal(t, "USD", resp.Currency)
assert.Len(t, resp.Costs, 1)

cost := resp.Costs[0]
assert.Equal(t, float32(100.0), cost.BilledCost)
assert.Equal(t, float32(100.0), cost.ListCost)
assert.Equal(t, float32(1.0), cost.ListUnitPrice)
}
14 changes: 10 additions & 4 deletions pkg/plugins/mongodb-atlas/config/atlasconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
)

type AtlasConfig struct {
PublicKey string `json:"atlas_public_key"`
PrivateKey string `json:"atlas_private_key"`
OrgID string `json:"atlas_org_id"`
LogLevel string `json:"atlas_plugin_log_level"`
PublicKey string `json:"atlas_public_key"`
PrivateKey string `json:"atlas_private_key"`
OrgID string `json:"atlas_org_id"`
LogLevel string `json:"atlas_plugin_log_level"`
TargetCurrency string `json:"target_currency"`
ExchangeAPIKey string `json:"exchange_api_key"`
}

func GetAtlasConfig(configFilePath string) (*AtlasConfig, error) {
Expand All @@ -28,5 +30,9 @@ func GetAtlasConfig(configFilePath string) (*AtlasConfig, error) {
result.LogLevel = "info"
}

if result.TargetCurrency == "" {
result.TargetCurrency = "USD"
}

return &result, nil
}
Loading