From 6b4f073c9799e64f3676b33c7a44e766879cbebe Mon Sep 17 00:00:00 2001 From: cbullinger Date: Tue, 24 Jun 2025 22:47:37 -0400 Subject: [PATCH 01/11] Add script for pulling historical billing info --- .../cmd/get_historical_billing/main.go | 61 +++++++++ .../atlas-sdk-go/cmd/get_linked_orgs/main.go | 50 +++++--- .../atlas-sdk-go/internal/billing/crossorg.go | 15 +-- .../atlas-sdk-go/internal/billing/invoices.go | 120 ++++++++++++++++++ .../internal/billing/linkedorgs.go | 8 +- .../internal/billing/linkedorgs_test.go | 8 +- 6 files changed, 225 insertions(+), 37 deletions(-) create mode 100644 usage-examples/go/atlas-sdk-go/cmd/get_historical_billing/main.go create mode 100644 usage-examples/go/atlas-sdk-go/internal/billing/invoices.go diff --git a/usage-examples/go/atlas-sdk-go/cmd/get_historical_billing/main.go b/usage-examples/go/atlas-sdk-go/cmd/get_historical_billing/main.go new file mode 100644 index 0000000..7a73274 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/cmd/get_historical_billing/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/billing" + "atlas-sdk-go/internal/config" + "context" + "fmt" + "github.com/joho/godotenv" + "log" + "time" +) + +// view invoices in the past six months for a given organization, including linked invoices + +func main() { + _ = godotenv.Load() + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + sdk, err := auth.NewClient(cfg, secrets) + if err != nil { + log.Fatalf("Failed to initialize client: %v", err) + } + + ctx := context.Background() + + fmt.Printf("Fetching historical invoices for organization: %s\n", cfg.OrgID) + + // TODO: confirm if we want to filter statuses or date range in this example + nonPendingStatuses := []string{ + // "PENDING", + "CLOSED", "FORGIVEN", "FAILED", "PAID", "FREE", "PREPAID", "INVOICED"} + invoices, err := billing.ListInvoicesForOrg(ctx, sdk.InvoicesApi, cfg.OrgID, + billing.WithStatusNames(nonPendingStatuses), + billing.WithViewLinkedInvoices(true), + billing.WithIncludeCount(true), + billing.WithDateRange(time.Now().AddDate(0, -6, 0), time.Now())) // past six months + + if err != nil { + log.Fatalf("Failed to retrieve invoices: %v", err) + } + + if invoices == nil || !invoices.HasResults() || len(invoices.GetResults()) == 0 { + fmt.Println("No invoices found") + return + } + + fmt.Printf("Found %d invoices\n", len(invoices.GetResults())) + for i, invoice := range invoices.GetResults() { + fmt.Printf(" %d. Invoice #%s - Status: %s - Created: %s - Amount: $%.d\n", + i+1, + invoice.GetId(), + invoice.GetStatusName(), + invoice.GetCreated(), + invoice.GetAmountBilledCents()/100.0) + } +} diff --git a/usage-examples/go/atlas-sdk-go/cmd/get_linked_orgs/main.go b/usage-examples/go/atlas-sdk-go/cmd/get_linked_orgs/main.go index fe8b0f7..6de133f 100644 --- a/usage-examples/go/atlas-sdk-go/cmd/get_linked_orgs/main.go +++ b/usage-examples/go/atlas-sdk-go/cmd/get_linked_orgs/main.go @@ -9,12 +9,10 @@ import ( "fmt" "log" - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" + "github.com/joho/godotenv" ) func main() { @@ -31,12 +29,9 @@ func main() { } ctx := context.Background() - params := &admin.ListInvoicesApiParams{ - OrgId: cfg.OrgID, - } - fmt.Printf("Fetching cross-org billing info for organization: %s\n", params.OrgId) - results, err := billing.GetCrossOrgBilling(ctx, sdk.InvoicesApi, params) + fmt.Printf("Fetching cross-org billing for organization: %s\n", cfg.OrgID) + results, err := billing.GetCrossOrgBilling(ctx, sdk.InvoicesApi, cfg.OrgID) if err != nil { log.Fatalf("Failed to retrieve invoices: %v", err) } @@ -45,18 +40,35 @@ func main() { return } - linkedOrgs, err := billing.GetLinkedOrgs(ctx, sdk.InvoicesApi, params) - if err != nil { - log.Fatalf("Failed to retrieve linked organizations: %v", err) - } - if len(linkedOrgs) == 0 { - fmt.Println("No linked organizations found for the billing org") - return - } - fmt.Println("Linked organizations:") - for i, org := range linkedOrgs { - fmt.Printf(" %d. %v\n", i+1, org) + // Print the returned map of invoices grouped by organization ID + fmt.Printf("Found %d organizations with invoices:\n", len(results)) + for orgID, invoices := range results { + fmt.Printf(" Organization ID: %s\n", orgID) + if len(invoices) == 0 { + fmt.Println(" No invoices found for this organization") + continue + } + for i, invoice := range invoices { + fmt.Printf(" %d. Invoice #%s - Status: %s - Created: %s - Amount: $%.2f\n", + i+1, + invoice.GetId(), + invoice.GetStatus(), + invoice.GetCreatedDate(), + invoice.GetAmountBilledCents()/100.0) + } } + // linkedOrgs, err := billing.ListLinkedOrgs(ctx, sdk.InvoicesApi, cfg.OrgID) + // if err != nil { + // log.Fatalf("Failed to retrieve linked organizations: %v", err) + // } + // if len(linkedOrgs) == 0 { + // fmt.Println("No linked organizations found for the billing org") + // return + // } + // fmt.Println("Linked organizations:") + // for i, org := range linkedOrgs { + // fmt.Printf(" %d. %v\n", i+1, org) + // } } // :snippet-end: [get-linked-orgs] diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/crossorg.go b/usage-examples/go/atlas-sdk-go/internal/billing/crossorg.go index e68edbd..6afb0ef 100644 --- a/usage-examples/go/atlas-sdk-go/internal/billing/crossorg.go +++ b/usage-examples/go/atlas-sdk-go/internal/billing/crossorg.go @@ -4,19 +4,14 @@ import ( "context" "go.mongodb.org/atlas-sdk/v20250219001/admin" - - "atlas-sdk-go/internal" ) -// GetCrossOrgBilling returns all invoices for the billing organization and any linked organizations. +// GetCrossOrgBilling returns a map of all billing invoices for the given organization and any linked organizations, grouped by organization ID. // NOTE: Organization Billing Admin or Organization Owner role required to view linked invoices. -func GetCrossOrgBilling(ctx context.Context, sdk admin.InvoicesApi, p *admin.ListInvoicesApiParams) (map[string][]admin.BillingInvoiceMetadata, error) { - req := sdk.ListInvoices(ctx, p.OrgId) - - r, _, err := req.Execute() - +func GetCrossOrgBilling(ctx context.Context, sdk admin.InvoicesApi, orgId string, opts ...InvoiceOption) (map[string][]admin.BillingInvoiceMetadata, error) { + r, err := ListInvoicesForOrg(ctx, sdk, orgId, opts...) if err != nil { - return nil, internal.FormatAPIError("list invoices", p.OrgId, err) + return nil, err } crossOrgBilling := make(map[string][]admin.BillingInvoiceMetadata) @@ -24,7 +19,7 @@ func GetCrossOrgBilling(ctx context.Context, sdk admin.InvoicesApi, p *admin.Lis return crossOrgBilling, nil } - crossOrgBilling[p.OrgId] = r.GetResults() + crossOrgBilling[orgId] = r.GetResults() for _, invoice := range r.GetResults() { if !invoice.HasLinkedInvoices() || len(invoice.GetLinkedInvoices()) == 0 { continue diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/invoices.go b/usage-examples/go/atlas-sdk-go/internal/billing/invoices.go new file mode 100644 index 0000000..01a26e5 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/billing/invoices.go @@ -0,0 +1,120 @@ +package billing + +import ( + "atlas-sdk-go/internal" + "context" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + "time" +) + +// InvoiceOption defines a function type that modifies the parameters for listing invoices. +type InvoiceOption func(*admin.ListInvoicesApiParams) + +// WithIncludeCount sets the optional includeCount parameter (default: true). +func WithIncludeCount(includeCount bool) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.IncludeCount = &includeCount + } +} + +// WithItemsPerPage sets the optional itemsPerPage parameter (default: 100). +func WithItemsPerPage(itemsPerPage int) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.ItemsPerPage = &itemsPerPage + } +} + +// WithPageNum sets the optional pageNum parameter (default: 1). +func WithPageNum(pageNum int) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.PageNum = &pageNum + } +} + +// WithViewLinkedInvoices sets the optional viewLinkedInvoices parameter (default: true). +func WithViewLinkedInvoices(viewLinkedInvoices bool) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.ViewLinkedInvoices = &viewLinkedInvoices + } +} + +// WithStatusNames sets the optional statusNames parameter (default: all statuses). +func WithStatusNames(statusNames []string) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.StatusNames = &statusNames + } +} + +// WithDateRange sets the optional fromDate and toDate parameters (default: all possible dates). +func WithDateRange(fromDate, toDate time.Time) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + from := fromDate.Format("2006-01-02") + to := toDate.Format("2006-01-02") + p.FromDate = &from + p.ToDate = &to + } +} + +// WithSortBy sets the optional sortBy parameter (default: "END_DATE"). +func WithSortBy(sortBy string) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.SortBy = &sortBy + } +} + +// WithOrderBy sets the optional orderBy parameter (default: "desc"). +func WithOrderBy(orderBy string) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.OrderBy = &orderBy + } +} + +// ListInvoicesForOrg returns all eligible invoices for the given organization, including linked organizations when cross-organization billing is enabled. If optional parameters aren't specified, default values are used. +// NOTE: Organization Billing Admin or Organization Owner role required to view linked invoices. +func ListInvoicesForOrg(ctx context.Context, sdk admin.InvoicesApi, orgId string, opts ...InvoiceOption) (*admin.PaginatedApiInvoiceMetadata, error) { + params := &admin.ListInvoicesApiParams{ + OrgId: orgId, + } + + for _, opt := range opts { + opt(params) + } + + req := sdk.ListInvoices(ctx, params.OrgId) + + if params.IncludeCount != nil { + req = req.IncludeCount(*params.IncludeCount) + } + if params.ItemsPerPage != nil { + req = req.ItemsPerPage(*params.ItemsPerPage) + } + if params.PageNum != nil { + req = req.PageNum(*params.PageNum) + } + if params.ViewLinkedInvoices != nil { + req = req.ViewLinkedInvoices(*params.ViewLinkedInvoices) + } + if params.StatusNames != nil { + req = req.StatusNames(*params.StatusNames) + } + if params.FromDate != nil { + req = req.FromDate(*params.FromDate) + } + if params.ToDate != nil { + req = req.ToDate(*params.ToDate) + } + if params.SortBy != nil { + req = req.SortBy(*params.SortBy) + } + if params.OrderBy != nil { + req = req.OrderBy(*params.OrderBy) + } + + r, _, err := req.Execute() + + if err != nil { + return nil, internal.FormatAPIError("list invoices", orgId, err) + } + + return r, nil +} diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go b/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go index 7d35a83..dbe8922 100644 --- a/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go +++ b/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go @@ -7,16 +7,16 @@ import ( "go.mongodb.org/atlas-sdk/v20250219001/admin" ) -// GetLinkedOrgs returns all linked organizations for a given billing organization. -func GetLinkedOrgs(ctx context.Context, sdk admin.InvoicesApi, p *admin.ListInvoicesApiParams) ([]string, error) { - invoices, err := GetCrossOrgBilling(ctx, sdk, p) +// ListLinkedOrgs returns all linked organizations for a given billing organization. +func ListLinkedOrgs(ctx context.Context, sdk admin.InvoicesApi, orgId string, opts ...InvoiceOption) ([]string, error) { + invoices, err := GetCrossOrgBilling(ctx, sdk, orgId, opts...) if err != nil { return nil, fmt.Errorf("get cross-org billing: %w", err) } var linkedOrgs []string for orgID := range invoices { - if orgID != p.OrgId { + if orgID != orgId { linkedOrgs = append(linkedOrgs, orgID) } } diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs_test.go b/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs_test.go index b6b0907..467797e 100644 --- a/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs_test.go +++ b/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs_test.go @@ -50,7 +50,7 @@ func TestGetLinkedOrgs_Success(t *testing.T) { Return(mockResponse, nil, nil).Once() params := &admin.ListInvoicesApiParams{OrgId: billingOrgID} - linkedOrgs, err := billing.GetLinkedOrgs(context.Background(), mockSvc, params) + linkedOrgs, err := billing.ListLinkedOrgs(context.Background(), mockSvc, params) require.NoError(t, err) assert.Len(t, linkedOrgs, 2, "Should return two linked organizations") @@ -72,7 +72,7 @@ func TestGetLinkedOrgs_ApiError(t *testing.T) { Return(nil, nil, expectedError).Once() params := &admin.ListInvoicesApiParams{OrgId: billingOrgID} - _, err := billing.GetLinkedOrgs(context.Background(), mockSvc, params) + _, err := billing.ListLinkedOrgs(context.Background(), mockSvc, params) // Verify error handling require.Error(t, err) @@ -103,7 +103,7 @@ func TestGetLinkedOrgs_NoLinkedOrgs(t *testing.T) { Return(mockResponse, nil, nil).Once() params := &admin.ListInvoicesApiParams{OrgId: billingOrgID} - linkedOrgs, err := billing.GetLinkedOrgs(context.Background(), mockSvc, params) + linkedOrgs, err := billing.ListLinkedOrgs(context.Background(), mockSvc, params) require.NoError(t, err) assert.Empty(t, linkedOrgs, "Should return empty when no linked orgs exist") @@ -137,7 +137,7 @@ func TestGetLinkedOrgs_MissingOrgID(t *testing.T) { // Run test params := &admin.ListInvoicesApiParams{OrgId: billingOrgID} - linkedOrgs, err := billing.GetLinkedOrgs(context.Background(), mockSvc, params) + linkedOrgs, err := billing.ListLinkedOrgs(context.Background(), mockSvc, params) require.NoError(t, err) assert.Len(t, linkedOrgs, 1, "Should return one linked organization") From 829723d6c2415a506ef6b0ab0b2952befa846d82 Mon Sep 17 00:00:00 2001 From: cbullinger Date: Tue, 1 Jul 2025 09:49:02 -0400 Subject: [PATCH 02/11] Refactor package names --- .../cmd/get_historical_billing/main.go | 61 -------- .../atlas-sdk-go/cmd/get_linked_orgs/main.go | 74 --------- .../examples/billing/historical/main.go | 94 ++++++++++++ .../examples/billing/line_items/main.go | 92 +++++++++++ .../examples/billing/linked_orgs/main.go | 78 ++++++++++ .../monitoring_logs}/main.go | 46 +++--- .../monitoring_metrics_disk}/main.go | 14 +- .../monitoring_metrics_process}/main.go | 14 +- .../go/atlas-sdk-go/internal/auth/client.go | 11 +- .../internal/billing/collector.go | 143 ++++++++++++++++++ .../atlas-sdk-go/internal/billing/crossorg.go | 38 ----- .../atlas-sdk-go/internal/billing/invoices.go | 35 +++-- .../internal/billing/linked_billing.go | 47 ++++++ ...rossorg_test.go => linked_billing_test.go} | 0 .../internal/billing/linkedorgs.go | 6 +- .../internal/billing/sku_classifier.go | 53 +++++++ .../atlas-sdk-go/internal/config/loadall.go | 6 +- .../internal/config/loadconfig.go | 5 +- .../internal/data/export/formats.go | 97 ++++++++++++ .../go/atlas-sdk-go/internal/errors/utils.go | 37 +++++ .../{logs/gzip.go => fileutils/compress.go} | 12 +- .../compress_test.go} | 2 +- .../internal/{utils.go => fileutils/io.go} | 25 +-- .../file_test.go => fileutils/io_test.go} | 2 +- .../atlas-sdk-go/internal/fileutils/paths.go | 27 ++++ .../go/atlas-sdk-go/internal/logs/fetch.go | 10 +- .../atlas-sdk-go/internal/logs/fetch_test.go | 5 +- .../go/atlas-sdk-go/internal/logs/file.go | 24 --- .../go/atlas-sdk-go/internal/metrics/disk.go | 8 +- .../atlas-sdk-go/internal/metrics/process.go | 5 +- 30 files changed, 781 insertions(+), 290 deletions(-) delete mode 100644 usage-examples/go/atlas-sdk-go/cmd/get_historical_billing/main.go delete mode 100644 usage-examples/go/atlas-sdk-go/cmd/get_linked_orgs/main.go create mode 100644 usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go create mode 100644 usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go create mode 100644 usage-examples/go/atlas-sdk-go/examples/billing/linked_orgs/main.go rename usage-examples/go/atlas-sdk-go/{cmd/get_logs => examples/monitoring_logs}/main.go (53%) rename usage-examples/go/atlas-sdk-go/{cmd/get_metrics_disk => examples/monitoring_metrics_disk}/main.go (81%) rename usage-examples/go/atlas-sdk-go/{cmd/get_metrics_process => examples/monitoring_metrics_process}/main.go (84%) create mode 100644 usage-examples/go/atlas-sdk-go/internal/billing/collector.go delete mode 100644 usage-examples/go/atlas-sdk-go/internal/billing/crossorg.go create mode 100644 usage-examples/go/atlas-sdk-go/internal/billing/linked_billing.go rename usage-examples/go/atlas-sdk-go/internal/billing/{crossorg_test.go => linked_billing_test.go} (100%) create mode 100644 usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier.go create mode 100644 usage-examples/go/atlas-sdk-go/internal/data/export/formats.go create mode 100644 usage-examples/go/atlas-sdk-go/internal/errors/utils.go rename usage-examples/go/atlas-sdk-go/internal/{logs/gzip.go => fileutils/compress.go} (73%) rename usage-examples/go/atlas-sdk-go/internal/{logs/gzip_test.go => fileutils/compress_test.go} (98%) rename usage-examples/go/atlas-sdk-go/internal/{utils.go => fileutils/io.go} (66%) rename usage-examples/go/atlas-sdk-go/internal/{logs/file_test.go => fileutils/io_test.go} (97%) create mode 100644 usage-examples/go/atlas-sdk-go/internal/fileutils/paths.go delete mode 100644 usage-examples/go/atlas-sdk-go/internal/logs/file.go diff --git a/usage-examples/go/atlas-sdk-go/cmd/get_historical_billing/main.go b/usage-examples/go/atlas-sdk-go/cmd/get_historical_billing/main.go deleted file mode 100644 index 7a73274..0000000 --- a/usage-examples/go/atlas-sdk-go/cmd/get_historical_billing/main.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/billing" - "atlas-sdk-go/internal/config" - "context" - "fmt" - "github.com/joho/godotenv" - "log" - "time" -) - -// view invoices in the past six months for a given organization, including linked invoices - -func main() { - _ = godotenv.Load() - - secrets, cfg, err := config.LoadAll("configs/config.json") - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - sdk, err := auth.NewClient(cfg, secrets) - if err != nil { - log.Fatalf("Failed to initialize client: %v", err) - } - - ctx := context.Background() - - fmt.Printf("Fetching historical invoices for organization: %s\n", cfg.OrgID) - - // TODO: confirm if we want to filter statuses or date range in this example - nonPendingStatuses := []string{ - // "PENDING", - "CLOSED", "FORGIVEN", "FAILED", "PAID", "FREE", "PREPAID", "INVOICED"} - invoices, err := billing.ListInvoicesForOrg(ctx, sdk.InvoicesApi, cfg.OrgID, - billing.WithStatusNames(nonPendingStatuses), - billing.WithViewLinkedInvoices(true), - billing.WithIncludeCount(true), - billing.WithDateRange(time.Now().AddDate(0, -6, 0), time.Now())) // past six months - - if err != nil { - log.Fatalf("Failed to retrieve invoices: %v", err) - } - - if invoices == nil || !invoices.HasResults() || len(invoices.GetResults()) == 0 { - fmt.Println("No invoices found") - return - } - - fmt.Printf("Found %d invoices\n", len(invoices.GetResults())) - for i, invoice := range invoices.GetResults() { - fmt.Printf(" %d. Invoice #%s - Status: %s - Created: %s - Amount: $%.d\n", - i+1, - invoice.GetId(), - invoice.GetStatusName(), - invoice.GetCreated(), - invoice.GetAmountBilledCents()/100.0) - } -} diff --git a/usage-examples/go/atlas-sdk-go/cmd/get_linked_orgs/main.go b/usage-examples/go/atlas-sdk-go/cmd/get_linked_orgs/main.go deleted file mode 100644 index 6de133f..0000000 --- a/usage-examples/go/atlas-sdk-go/cmd/get_linked_orgs/main.go +++ /dev/null @@ -1,74 +0,0 @@ -// :snippet-start: get-linked-orgs -// :state-remove-start: copy -// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk -// :state-remove-end: [copy] -package main - -import ( - "context" - "fmt" - "log" - - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/billing" - "atlas-sdk-go/internal/config" - "github.com/joho/godotenv" -) - -func main() { - _ = godotenv.Load() - - secrets, cfg, err := config.LoadAll("configs/config.json") - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - sdk, err := auth.NewClient(cfg, secrets) - if err != nil { - log.Fatalf("Failed to initialize client: %v", err) - } - - ctx := context.Background() - - fmt.Printf("Fetching cross-org billing for organization: %s\n", cfg.OrgID) - results, err := billing.GetCrossOrgBilling(ctx, sdk.InvoicesApi, cfg.OrgID) - if err != nil { - log.Fatalf("Failed to retrieve invoices: %v", err) - } - if len(results) == 0 { - fmt.Println("No invoices found for the billing organization") - return - } - - // Print the returned map of invoices grouped by organization ID - fmt.Printf("Found %d organizations with invoices:\n", len(results)) - for orgID, invoices := range results { - fmt.Printf(" Organization ID: %s\n", orgID) - if len(invoices) == 0 { - fmt.Println(" No invoices found for this organization") - continue - } - for i, invoice := range invoices { - fmt.Printf(" %d. Invoice #%s - Status: %s - Created: %s - Amount: $%.2f\n", - i+1, - invoice.GetId(), - invoice.GetStatus(), - invoice.GetCreatedDate(), - invoice.GetAmountBilledCents()/100.0) - } - } - // linkedOrgs, err := billing.ListLinkedOrgs(ctx, sdk.InvoicesApi, cfg.OrgID) - // if err != nil { - // log.Fatalf("Failed to retrieve linked organizations: %v", err) - // } - // if len(linkedOrgs) == 0 { - // fmt.Println("No linked organizations found for the billing org") - // return - // } - // fmt.Println("Linked organizations:") - // for i, org := range linkedOrgs { - // fmt.Printf(" %d. %v\n", i+1, org) - // } -} - -// :snippet-end: [get-linked-orgs] diff --git a/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go b/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go new file mode 100644 index 0000000..00c8103 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go @@ -0,0 +1,94 @@ +// :snippet-start: historical-billing +// :state-remove-start: copy +// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk +// :state-remove-end: [copy] +package main + +import ( + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/data/export" + "atlas-sdk-go/internal/fileutils" + "context" + "fmt" + "log" + "time" + + "github.com/joho/godotenv" + + "atlas-sdk-go/internal/billing" +) + +// :remove-start: +// TODO: QUESTION FOR REVIEWER: currently set to pull the past 6 months from current; do we want any additional configurations in this example? +// :remove-end: + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + log.Fatalf("config: failed to load file: %v", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + log.Fatalf("auth: failed client init: %v", err) + } + + ctx := context.Background() + + fmt.Printf("Fetching historical invoices for organization: %s\n", cfg.OrgID) + + invoices, err := billing.ListInvoicesForOrg(ctx, client.InvoicesApi, cfg.OrgID, + billing.WithViewLinkedInvoices(true), + billing.WithIncludeCount(true), + billing.WithDateRange(time.Now().AddDate(0, -6, 0), time.Now())) // previous six months + if err != nil { + log.Fatalf("billing: cannot retrieve invoices: %v", err) + } + + if invoices.GetTotalCount() > 0 { + fmt.Printf("Total count of invoices: %d\n", invoices.GetTotalCount()) + } else { + fmt.Println("No invoices found for the specified date range.") + return + } + + // Export invoice data to JSON and CSV file formats + outDir := "invoices" + prefix := fmt.Sprintf("historical_%s", cfg.OrgID) + + jsonPath, err := fileutils.GenerateOutputPath(outDir, prefix, "json") + if err != nil { + log.Fatalf("common: generate output path: %v", err) + } + if err := export.ToJSON(invoices.GetResults(), jsonPath); err != nil { + log.Fatalf("json: write file: %v", err) + } + fmt.Printf("Exported invoice data to %s\n", jsonPath) + + csvPath, err := fileutils.GenerateOutputPath(outDir, prefix, "csv") + if err != nil { + log.Fatalf("common: generate output path: %v", err) + } + + headers := []string{"InvoiceID", "Status", "Created", "AmountBilled"} + + err = export.ToCSVWithMapper(invoices.GetResults(), csvPath, headers, func(invoice billing.InvoiceOption) []string { + return []string{ + invoice.GetId(), + invoice.GetStatusName(), + invoice.GetCreated().Format(time.RFC3339), + fmt.Sprintf("%.2f", float64(invoice.GetAmountBilledCents())/100.0), + } + }) + if err != nil { + log.Fatalf("export: failed to write CSV file: %v", err) + } + fmt.Printf("Exported invoice data to %s\n", csvPath) +} + +// :snippet-end: [historical-billing] diff --git a/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go b/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go new file mode 100644 index 0000000..d020827 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go @@ -0,0 +1,92 @@ +// :snippet-start: line-items +// :state-remove-start: copy +// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk +// :state-remove-end: [copy] +package main + +import ( + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/billing" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/data/export" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/fileutils" + "context" + "fmt" + "log" + + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/ignore.config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + OrgID := cfg.OrgID + + fmt.Printf("Fetching pending invoices for organization: %s\n", OrgID) + + details, err := billing.CollectLineItemBillingData(ctx, client.InvoicesApi, client.OrganizationsApi, OrgID, nil) + if err != nil { + errors.ExitWithError("Failed to fetch billing data", err) + } + + fmt.Printf("Found %d line items in pending invoices\n", len(details)) + + outDir := "invoices" + prefix := fmt.Sprintf("pending_%s", OrgID) + + // Export to JSON + jsonPath, err := fileutils.GenerateOutputPath(outDir, prefix, "json") + if err != nil { + errors.ExitWithError("Failed to generate JSON output path", err) + } + + if err := export.ToJSON(details, jsonPath); err != nil { + errors.ExitWithError("Failed to write JSON file", err) + } + fmt.Printf("Exported billing data to %s\n", jsonPath) + + // Export to CSV file + csvPath, err := fileutils.GenerateOutputPath(outDir, prefix, "csv") + if err != nil { + errors.ExitWithError("Failed to generate CSV output path", err) + } + + headers := []string{"Organization", "OrgID", "Project", "ProjectID", "Cluster", + "SKU", "Cost", "Date", "Provider", "Instance", "Category"} + + err = export.ToCSVWithMapper(details, csvPath, headers, func(item billing.Detail) []string { + return []string{ + item.Org.Name, + item.Org.ID, + item.Project.Name, + item.Project.ID, + item.Cluster, + item.SKU, + fmt.Sprintf("%.2f", item.Cost), + item.Date.Format("2006-01-02"), + item.Provider, + item.Instance, + item.Category, + } + }) + if err != nil { + errors.ExitWithError("Failed to write CSV file", err) + } + fmt.Printf("Exported billing data to %s\n", csvPath) +} + +// :snippet-end: [line-items] diff --git a/usage-examples/go/atlas-sdk-go/examples/billing/linked_orgs/main.go b/usage-examples/go/atlas-sdk-go/examples/billing/linked_orgs/main.go new file mode 100644 index 0000000..4848520 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/examples/billing/linked_orgs/main.go @@ -0,0 +1,78 @@ +// :snippet-start: linked-billing +// :state-remove-start: copy +// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk +// :state-remove-end: [copy] +package main + +import ( + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/billing" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" + "context" + "fmt" + "log" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + orgID := cfg.OrgID + + fmt.Printf("Fetching linked organizations for billing organization: %s\n", orgID) + + invoices, err := billing.GetCrossOrgBilling(ctx, client.InvoicesApi, orgID) + if err != nil { + errors.ExitWithError(fmt.Sprintf("Failed to retrieve cross-organization billing data for %s", orgID), err) + } + + displayLinkedOrganizations(invoices, orgID) +} + +func displayLinkedOrganizations(invoices map[string][]admin.BillingInvoiceMetadata, primaryOrgID string) { + var linkedOrgs []string + for orgID := range invoices { + if orgID != primaryOrgID { + linkedOrgs = append(linkedOrgs, orgID) + } + } + + if len(linkedOrgs) == 0 { + fmt.Println("No linked organizations found for the billing organization") + return + } + + fmt.Printf("Found %d linked organizations:\n", len(linkedOrgs)) + for i, orgID := range linkedOrgs { + fmt.Printf(" %d. Organization ID: %s\n", i+1, orgID) + } +} + +// :snippet-end: [linked-billing] +// :state-remove-start: copy +// ** OUTPUT EXAMPLE ** +// +// Fetching linked organizations for billing organization: 5f7a9ec7d78fc03b42959328 +// +// Found 4 linked organizations: +// 1. Organization ID: 61f4d5e2bf82763afcd12e45 +// 2. Organization ID: 62a1b937c845d9f216890c72 +// 3. Organization ID: 60c8f71e4d8a219b37a5d90f +// 4. Organization ID: 63e7d2c8a19b4f7654321abc +// :state-remove-end: [copy] diff --git a/usage-examples/go/atlas-sdk-go/cmd/get_logs/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring_logs/main.go similarity index 53% rename from usage-examples/go/atlas-sdk-go/cmd/get_logs/main.go rename to usage-examples/go/atlas-sdk-go/examples/monitoring_logs/main.go index a90ced0..117f52e 100644 --- a/usage-examples/go/atlas-sdk-go/cmd/get_logs/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/monitoring_logs/main.go @@ -5,32 +5,31 @@ package main import ( + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" "context" "fmt" "log" - "os" - "path/filepath" - "time" "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" - "atlas-sdk-go/internal" - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/fileutils" "atlas-sdk-go/internal/logs" ) func main() { - _ = godotenv.Load() + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } secrets, cfg, err := config.LoadAll("configs/config.json") if err != nil { - log.Fatalf("config load: %v", err) + log.Fatalf("config: failed to load file: %v", err) } sdk, err := auth.NewClient(cfg, secrets) if err != nil { - log.Fatalf("client init: %v", err) + log.Fatalf("auth: failed client init: %v", err) } ctx := context.Background() @@ -39,31 +38,36 @@ func main() { HostName: cfg.HostName, LogName: "mongodb", } - ts := time.Now().Format("20060102_150405") - base := fmt.Sprintf("%s_%s_%s", p.HostName, p.LogName, ts) + outDir := "logs" - os.MkdirAll(outDir, 0o755) - gzPath := filepath.Join(outDir, base+".gz") - txtPath := filepath.Join(outDir, base+".txt") + prefix := fmt.Sprintf("%s_%s", p.HostName, p.LogName) + gzPath, err := fileutils.GenerateOutputPath(outDir, prefix, "gz") + if err != nil { + log.Fatalf("common: failed to generate output path: %v", err) + } + txtPath, err := fileutils.GenerateOutputPath(outDir, prefix, ".txt") + if err != nil { + log.Fatalf("common: failed to generate output path: %v", err) + } rc, err := logs.FetchHostLogs(ctx, sdk.MonitoringAndLogsApi, p) if err != nil { - log.Fatalf("download logs: %v", err) + log.Fatalf("logs: failed to fetch logs: %v", err) } - defer internal.SafeClose(rc) + defer fileutils.SafeClose(rc) - if err := logs.WriteToFile(rc, gzPath); err != nil { - log.Fatalf("save gz: %v", err) + if err := fileutils.WriteToFile(rc, gzPath); err != nil { + log.Fatalf("fileutils: failed to save gz: %v", err) } fmt.Println("Saved compressed log to", gzPath) - if err := logs.DecompressGzip(gzPath, txtPath); err != nil { - log.Fatalf("decompress: %v", err) + if err := fileutils.DecompressGzip(gzPath, txtPath); err != nil { + log.Fatalf("fileutils: failed to decompress gz: %v", err) } fmt.Println("Uncompressed log to", txtPath) // :remove-start: // NOTE: Internal-only function to clean up any downloaded files - if err := internal.SafeDelete(outDir); err != nil { + if err := fileutils.SafeDelete(outDir); err != nil { log.Printf("Cleanup error: %v", err) } fmt.Println("Deleted generated files from", outDir) diff --git a/usage-examples/go/atlas-sdk-go/cmd/get_metrics_disk/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_disk/main.go similarity index 81% rename from usage-examples/go/atlas-sdk-go/cmd/get_metrics_disk/main.go rename to usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_disk/main.go index 2520d47..a86bcd5 100644 --- a/usage-examples/go/atlas-sdk-go/cmd/get_metrics_disk/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_disk/main.go @@ -5,6 +5,8 @@ package main import ( + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" "context" "encoding/json" "fmt" @@ -13,21 +15,21 @@ import ( "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/metrics" ) func main() { - _ = godotenv.Load() + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } secrets, cfg, err := config.LoadAll("configs/config.json") if err != nil { - log.Fatalf("config load: %v", err) + log.Fatalf("config: load config file: %v", err) } sdk, err := auth.NewClient(cfg, secrets) if err != nil { - log.Fatalf("client init: %v", err) + log.Fatalf("auth: client init: %v", err) } ctx := context.Background() @@ -42,7 +44,7 @@ func main() { view, err := metrics.FetchDiskMetrics(ctx, sdk.MonitoringAndLogsApi, p) if err != nil { - log.Fatalf("disk metrics: %v", err) + log.Fatalf("metrics: fetch disk metrics: %v", err) } out, _ := json.MarshalIndent(view, "", " ") diff --git a/usage-examples/go/atlas-sdk-go/cmd/get_metrics_process/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_process/main.go similarity index 84% rename from usage-examples/go/atlas-sdk-go/cmd/get_metrics_process/main.go rename to usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_process/main.go index db707c4..fbfd70e 100644 --- a/usage-examples/go/atlas-sdk-go/cmd/get_metrics_process/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_process/main.go @@ -5,6 +5,8 @@ package main import ( + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" "context" "encoding/json" "fmt" @@ -13,21 +15,21 @@ import ( "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/metrics" ) func main() { - _ = godotenv.Load() + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } secrets, cfg, err := config.LoadAll("configs/config.json") if err != nil { - log.Fatalf("config load: %v", err) + log.Fatalf("config: load config file: %v", err) } sdk, err := auth.NewClient(cfg, secrets) if err != nil { - log.Fatalf("client init: %v", err) + log.Fatalf("auth: client init: %v", err) } ctx := context.Background() @@ -47,7 +49,7 @@ func main() { view, err := metrics.FetchProcessMetrics(ctx, sdk.MonitoringAndLogsApi, p) if err != nil { - log.Fatalf("process metrics: %v", err) + log.Fatalf("metrics: fetch process metrics: %v", err) } out, _ := json.MarshalIndent(view, "", " ") diff --git a/usage-examples/go/atlas-sdk-go/internal/auth/client.go b/usage-examples/go/atlas-sdk-go/internal/auth/client.go index 7e8f192..3af6f7d 100644 --- a/usage-examples/go/atlas-sdk-go/internal/auth/client.go +++ b/usage-examples/go/atlas-sdk-go/internal/auth/client.go @@ -1,16 +1,15 @@ package auth import ( - "context" - "fmt" - "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" + "context" "go.mongodb.org/atlas-sdk/v20250219001/admin" ) -// NewClient initializes and returns an authenticated Atlas API client -// using OAuth2 with service account credentials (recommended) +// NewClient initializes and returns an authenticated Atlas API client using OAuth2 with service account credentials (recommended) +// See: https://www.mongodb.com/docs/atlas/architecture/current/auth/#service-accounts func NewClient(cfg *config.Config, secrets *config.Secrets) (*admin.APIClient, error) { sdk, err := admin.NewClient( admin.UseBaseURL(cfg.BaseURL), @@ -20,7 +19,7 @@ func NewClient(cfg *config.Config, secrets *config.Secrets) (*admin.APIClient, e ), ) if err != nil { - return nil, fmt.Errorf("create atlas client: %w", err) + return nil, errors.WithContext(err, "create atlas client") } return sdk, nil } diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/collector.go b/usage-examples/go/atlas-sdk-go/internal/billing/collector.go new file mode 100644 index 0000000..193ed83 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/billing/collector.go @@ -0,0 +1,143 @@ +package billing + +import ( + "atlas-sdk-go/internal/errors" + "context" + "fmt" + "time" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// Detail represents the transformed billing line item +type Detail struct { + Org OrgInfo `json:"org"` + Project ProjectInfo `json:"project"` + Cluster string `json:"cluster"` + SKU string `json:"sku"` + Cost float64 `json:"cost"` + Date time.Time `json:"date"` + Provider string `json:"provider"` + Instance string `json:"instance"` + Category string `json:"category"` +} + +// OrgInfo contains organization identifier information +type OrgInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// ProjectInfo contains project identifier information +type ProjectInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// CollectLineItemBillingData fetches all pending invoices for the specified organization and extracts line items with tags +func CollectLineItemBillingData(ctx context.Context, sdk admin.InvoicesApi, orgSdk admin.OrganizationsApi, orgID string, lastProcessedDate *time.Time) ([]Detail, error) { + req := sdk.ListPendingInvoices(ctx, orgID) + r, _, err := req.Execute() + + if err != nil { + return nil, errors.FormatError("list pending invoices", orgID, err) + } + if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { + return nil, &errors.NotFoundError{ + Resource: "pending invoices", + ID: orgID, + } + } + + fmt.Printf("Found %d pending invoice(s)\n", len(r.GetResults())) + + // Get organization name + orgName, err := getOrganizationName(ctx, orgSdk, orgID) + if err != nil { + // Non-critical error, continue with orgID as name + fmt.Printf("Warning: %v\n", err) + orgName = orgID + } + + // Process invoices and collect line items + billingDetails, err := processInvoices(r.GetResults(), orgID, orgName, lastProcessedDate) + if err != nil { + return nil, errors.WithContext(err, "processing invoices") + } + + if len(billingDetails) == 0 { + return nil, &errors.NotFoundError{ + Resource: "line items in pending invoices", + ID: orgID, + } + } + + return billingDetails, nil +} + +// processInvoices extracts and transforms line items from invoices +func processInvoices(invoices []admin.BillingInvoice, orgID, orgName string, lastProcessedDate *time.Time) ([]Detail, error) { + var billingDetails []Detail + + for _, invoice := range invoices { + fmt.Printf("Processing invoice ID: %s\n", invoice.GetId()) + + for _, lineItem := range invoice.GetLineItems() { + // Parse start date + startDate := lineItem.GetStartDate() + + // Skip if older than last processed date + if lastProcessedDate != nil && !startDate.After(*lastProcessedDate) { + continue + } + + // Create transformed billing detail + detail := Detail{ + Org: OrgInfo{ + ID: orgID, + Name: orgName, + }, + Project: ProjectInfo{ + ID: lineItem.GetGroupId(), + Name: lineItem.GetGroupName(), + }, + Cluster: getValueOrDefault(lineItem.GetClusterName(), "--n/a--"), + SKU: lineItem.GetSku(), + Cost: float64(lineItem.GetTotalPriceCents()) / 100.0, + Date: startDate, + Provider: determineProvider(lineItem.GetSku()), + Instance: determineInstance(lineItem.GetSku()), + Category: determineCategory(lineItem.GetSku()), + } + billingDetails = append(billingDetails, detail) + } + } + + return billingDetails, nil +} + +// getOrganizationName fetches organization name from API or returns orgID if not found +func getOrganizationName(ctx context.Context, sdk admin.OrganizationsApi, orgID string) (string, error) { + req := sdk.GetOrganization(ctx, orgID) + org, _, err := req.Execute() + if err != nil { + return orgID, errors.FormatError("get organization details", orgID, err) + } + if org == nil { + return orgID, fmt.Errorf("organization response is nil for ID %s", orgID) + } + return org.GetName(), nil +} + +// getValueOrDefault returns the value or a default if empty +func getValueOrDefault(value string, defaultValue string) string { + if value == "" { + return defaultValue + } + return value +} + +// VerifyDataCompleteness compares source and transformed data counts +func VerifyDataCompleteness(sourceCount, transformedCount int) bool { + return sourceCount == transformedCount +} diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/crossorg.go b/usage-examples/go/atlas-sdk-go/internal/billing/crossorg.go deleted file mode 100644 index 6afb0ef..0000000 --- a/usage-examples/go/atlas-sdk-go/internal/billing/crossorg.go +++ /dev/null @@ -1,38 +0,0 @@ -package billing - -import ( - "context" - - "go.mongodb.org/atlas-sdk/v20250219001/admin" -) - -// GetCrossOrgBilling returns a map of all billing invoices for the given organization and any linked organizations, grouped by organization ID. -// NOTE: Organization Billing Admin or Organization Owner role required to view linked invoices. -func GetCrossOrgBilling(ctx context.Context, sdk admin.InvoicesApi, orgId string, opts ...InvoiceOption) (map[string][]admin.BillingInvoiceMetadata, error) { - r, err := ListInvoicesForOrg(ctx, sdk, orgId, opts...) - if err != nil { - return nil, err - } - - crossOrgBilling := make(map[string][]admin.BillingInvoiceMetadata) - if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { - return crossOrgBilling, nil - } - - crossOrgBilling[orgId] = r.GetResults() - for _, invoice := range r.GetResults() { - if !invoice.HasLinkedInvoices() || len(invoice.GetLinkedInvoices()) == 0 { - continue - } - - for _, linkedInvoice := range invoice.GetLinkedInvoices() { - orgID := linkedInvoice.GetOrgId() - if orgID == "" { - continue - } - crossOrgBilling[orgID] = append(crossOrgBilling[orgID], linkedInvoice) - } - } - - return crossOrgBilling, nil -} diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/invoices.go b/usage-examples/go/atlas-sdk-go/internal/billing/invoices.go index 01a26e5..c963bac 100644 --- a/usage-examples/go/atlas-sdk-go/internal/billing/invoices.go +++ b/usage-examples/go/atlas-sdk-go/internal/billing/invoices.go @@ -1,10 +1,11 @@ package billing import ( - "atlas-sdk-go/internal" + "atlas-sdk-go/internal/errors" "context" - "go.mongodb.org/atlas-sdk/v20250219001/admin" "time" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) // InvoiceOption defines a function type that modifies the parameters for listing invoices. @@ -38,7 +39,8 @@ func WithViewLinkedInvoices(viewLinkedInvoices bool) InvoiceOption { } } -// WithStatusNames sets the optional statusNames parameter (default: all statuses). +// WithStatusNames sets the optional statusNames parameter (default: include all statuses). +// Possible status names: "PENDING" "CLOSED" "FORGIVEN" "FAILED" "PAID" "FREE" "PREPAID" "INVOICED" func WithStatusNames(statusNames []string) InvoiceOption { return func(p *admin.ListInvoicesApiParams) { p.StatusNames = &statusNames @@ -48,8 +50,8 @@ func WithStatusNames(statusNames []string) InvoiceOption { // WithDateRange sets the optional fromDate and toDate parameters (default: all possible dates). func WithDateRange(fromDate, toDate time.Time) InvoiceOption { return func(p *admin.ListInvoicesApiParams) { - from := fromDate.Format("2006-01-02") - to := toDate.Format("2006-01-02") + from := fromDate.Format(time.DateOnly) // Format to "YYYY-MM-DD" string + to := toDate.Format(time.DateOnly) // Format to "YYYY-MM-DD" string p.FromDate = &from p.ToDate = &to } @@ -69,11 +71,19 @@ func WithOrderBy(orderBy string) InvoiceOption { } } -// ListInvoicesForOrg returns all eligible invoices for the given organization, including linked organizations when cross-organization billing is enabled. If optional parameters aren't specified, default values are used. -// NOTE: Organization Billing Admin or Organization Owner role required to view linked invoices. -func ListInvoicesForOrg(ctx context.Context, sdk admin.InvoicesApi, orgId string, opts ...InvoiceOption) (*admin.PaginatedApiInvoiceMetadata, error) { +// ListInvoicesForOrg returns all eligible invoices for the given organization, +// including linked organizations when cross-organization billing is enabled. +// It accepts a context for the request, an InvoicesApi client instance, the ID of the +// organization to retrieve invoices for, and optional query parameters. +// It returns the invoice results or an error if the invoice retrieval fails. +// Use options to customize pagination, filtering, and sorting (see With* functions). +// +// Required Permissions: +// - Organization Billing Viewer role can view invoices for the organization. +// - Organization Billing Admin or Organization Owner role can view invoices and linked invoices for the organization. +func ListInvoicesForOrg(ctx context.Context, sdk admin.InvoicesApi, orgID string, opts ...InvoiceOption) (*admin.PaginatedApiInvoiceMetadata, error) { params := &admin.ListInvoicesApiParams{ - OrgId: orgId, + OrgId: orgID, } for _, opt := range opts { @@ -111,10 +121,11 @@ func ListInvoicesForOrg(ctx context.Context, sdk admin.InvoicesApi, orgId string } r, _, err := req.Execute() - if err != nil { - return nil, internal.FormatAPIError("list invoices", orgId, err) + return nil, errors.FormatError("list invoices", orgID, err) + } + if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { + return nil, &errors.NotFoundError{Resource: "Invoices", ID: orgID} } - return r, nil } diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/linked_billing.go b/usage-examples/go/atlas-sdk-go/internal/billing/linked_billing.go new file mode 100644 index 0000000..8aeeaf7 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/billing/linked_billing.go @@ -0,0 +1,47 @@ +package billing + +import ( + "atlas-sdk-go/internal/errors" + "context" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// GetCrossOrgBilling returns a map of all billing invoices for the given organization +// and any linked organizations, grouped by organization ID. +// It accepts a context for the request, an InvoicesApi client instance, the ID of the +// organization to retrieve invoices for, and optional parameters to customize the query. +// It returns a map of organization IDs as keys with corresponding slices of metadata +// as values or an error if the invoice retrieval fails. +// +// Required Permissions: +// - Organization Billing Viewer role can view invoices for the organization. +// - Organization Billing Admin or Organization Owner role can view invoices and linked invoices for the organization. +func GetCrossOrgBilling(ctx context.Context, sdk admin.InvoicesApi, orgID string, opts ...InvoiceOption) (map[string][]admin.BillingInvoiceMetadata, error) { + r, err := ListInvoicesForOrg(ctx, sdk, orgID, opts...) + if err != nil { + return nil, errors.FormatError("get cross-organization billing", orgID, err) + } + + crossOrgBilling := make(map[string][]admin.BillingInvoiceMetadata) + if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { + return crossOrgBilling, nil + } + + crossOrgBilling[orgID] = r.GetResults() + for _, invoice := range r.GetResults() { + if !invoice.HasLinkedInvoices() || len(invoice.GetLinkedInvoices()) == 0 { + continue + } + + for _, linkedInvoice := range invoice.GetLinkedInvoices() { + orgID := linkedInvoice.GetOrgId() + if orgID == "" { + continue + } + crossOrgBilling[orgID] = append(crossOrgBilling[orgID], linkedInvoice) + } + } + + return crossOrgBilling, nil +} diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/crossorg_test.go b/usage-examples/go/atlas-sdk-go/internal/billing/linked_billing_test.go similarity index 100% rename from usage-examples/go/atlas-sdk-go/internal/billing/crossorg_test.go rename to usage-examples/go/atlas-sdk-go/internal/billing/linked_billing_test.go diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go b/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go index dbe8922..c57fd76 100644 --- a/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go +++ b/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go @@ -8,15 +8,15 @@ import ( ) // ListLinkedOrgs returns all linked organizations for a given billing organization. -func ListLinkedOrgs(ctx context.Context, sdk admin.InvoicesApi, orgId string, opts ...InvoiceOption) ([]string, error) { - invoices, err := GetCrossOrgBilling(ctx, sdk, orgId, opts...) +func ListLinkedOrgs(ctx context.Context, sdk admin.InvoicesApi, orgID string, opts ...InvoiceOption) ([]string, error) { + invoices, err := GetCrossOrgBilling(ctx, sdk, orgID, opts...) if err != nil { return nil, fmt.Errorf("get cross-org billing: %w", err) } var linkedOrgs []string for orgID := range invoices { - if orgID != orgId { + if orgID != orgID { linkedOrgs = append(linkedOrgs, orgID) } } diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier.go b/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier.go new file mode 100644 index 0000000..c65dc1a --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier.go @@ -0,0 +1,53 @@ +package billing + +import ( + "strings" +) + +// determineProvider identifies the cloud provider based on SKU +func determineProvider(sku string) string { + if strings.Contains(sku, "AWS") { + return "AWS" + } else if strings.Contains(sku, "AZURE") { + return "AZURE" + } else if strings.Contains(sku, "GCP") { + return "GCP" + } + return "n/a" +} + +// determineInstance extracts the instance type from SKU +func determineInstance(sku string) string { + parts := strings.Split(sku, "_INSTANCE_") + if len(parts) > 1 { + return parts[1] + } + return "non-instance" +} + +// determineCategory categorizes the SKU +func determineCategory(sku string) string { + categoryPatterns := map[string]string{ + "_INSTANCE": "instances", + "BACKUP": "backup", + "PIT_RESTORE": "backup", + "DATA_TRANSFER": "data xfer", + "STORAGE": "storage", + "BI_CONNECTOR": "bi-connector", + "DATA_LAKE": "data lake", + "AUDITING": "audit", + "ATLAS_SUPPORT": "support", + "FREE_SUPPORT": "free support", + "CHARTS": "charts", + "SERVERLESS": "serverless", + "SECURITY": "security", + "PRIVATE_ENDPOINT": "private endpoint", + } + + for pattern, category := range categoryPatterns { + if strings.Contains(sku, pattern) { + return category + } + } + return "other" +} diff --git a/usage-examples/go/atlas-sdk-go/internal/config/loadall.go b/usage-examples/go/atlas-sdk-go/internal/config/loadall.go index 6cad472..d6b3110 100644 --- a/usage-examples/go/atlas-sdk-go/internal/config/loadall.go +++ b/usage-examples/go/atlas-sdk-go/internal/config/loadall.go @@ -1,19 +1,19 @@ package config import ( - "fmt" + "atlas-sdk-go/internal/errors" ) // LoadAll loads secrets and config from the specified paths func LoadAll(configPath string) (*Secrets, *Config, error) { s, err := LoadSecrets() if err != nil { - return nil, nil, fmt.Errorf("loading secrets: %w", err) + return nil, nil, errors.WithContext(err, "loading secrets") } c, err := LoadConfig(configPath) if err != nil { - return nil, nil, fmt.Errorf("loading config: %w", err) + return nil, nil, errors.WithContext(err, "loading config") } return s, c, nil diff --git a/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go b/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go index e55cd57..eba1a0f 100644 --- a/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go +++ b/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go @@ -1,12 +1,11 @@ package config import ( + "atlas-sdk-go/internal/fileutils" "encoding/json" "fmt" "os" "strings" - - "atlas-sdk-go/internal" ) type Config struct { @@ -23,7 +22,7 @@ func LoadConfig(path string) (*Config, error) { if err != nil { return nil, fmt.Errorf("open config %s: %w", path, err) } - defer internal.SafeClose(f) + defer fileutils.SafeClose(f) var c Config if err := json.NewDecoder(f).Decode(&c); err != nil { diff --git a/usage-examples/go/atlas-sdk-go/internal/data/export/formats.go b/usage-examples/go/atlas-sdk-go/internal/data/export/formats.go new file mode 100644 index 0000000..2b30229 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/data/export/formats.go @@ -0,0 +1,97 @@ +package export + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os" + + "atlas-sdk-go/internal/fileutils" +) + +// ToJSON starts a goroutine to encode and write JSON data to a file +func ToJSON(data interface{}, filePath string) error { + if data == nil { + return fmt.Errorf("data cannot be nil") + } + if filePath == "" { + return fmt.Errorf("filePath cannot be empty") + } + + pr, pw := io.Pipe() + + encodeErrCh := make(chan error, 1) + go func() { + encoder := json.NewEncoder(pw) + encoder.SetIndent("", " ") + err := encoder.Encode(data) + if err != nil { + encodeErrCh <- err + pw.CloseWithError(fmt.Errorf("json encode: %w", err)) + return + } + encodeErrCh <- nil + fileutils.SafeClose(pw) + }() + + writeErr := fileutils.WriteToFile(pr, filePath) + + if encodeErr := <-encodeErrCh; encodeErr != nil { + return fmt.Errorf("json encode: %w", encodeErr) + } + + return writeErr +} + +// ToCSV starts a goroutine to encode and write CSV data to a file +// ToCSV writes data in CSV format to a file +func ToCSV(data [][]string, filePath string) error { + if data == nil { + return fmt.Errorf("data cannot be nil") + } + if filePath == "" { + return fmt.Errorf("filePath cannot be empty") + } + + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer fileutils.SafeClose(file) + + writer := csv.NewWriter(file) + defer writer.Flush() + + for _, row := range data { + if err := writer.Write(row); err != nil { + return fmt.Errorf("write csv row: %w", err) + } + } + + return nil +} + +// ToCSVWithMapper provides a generic method to convert domain objects to CSV data. +// It exports any slice of data to CSV with custom headers and row mapping +func ToCSVWithMapper[T any](data []T, filePath string, headers []string, rowMapper func(T) []string) error { + if data == nil { + return fmt.Errorf("data cannot be nil") + } + if len(headers) == 0 { + return fmt.Errorf("headers cannot be empty") + } + if rowMapper == nil { + return fmt.Errorf("rowMapper function cannot be nil") + } + + // Convert data to CSV format + rows := make([][]string, 0, len(data)+1) + rows = append(rows, headers) + + for _, item := range data { + rows = append(rows, rowMapper(item)) + } + + return ToCSV(rows, filePath) +} diff --git a/usage-examples/go/atlas-sdk-go/internal/errors/utils.go b/usage-examples/go/atlas-sdk-go/internal/errors/utils.go new file mode 100644 index 0000000..bc8fbdb --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/errors/utils.go @@ -0,0 +1,37 @@ +package errors + +import ( + "fmt" + "log" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// FormatError formats an error message for a specific operation and entity ID. +func FormatError(operation string, entityID string, err error) error { + if apiErr, ok := admin.AsError(err); ok && apiErr.GetDetail() != "" { + return fmt.Errorf("%s for %s: %w: %s", operation, entityID, err, apiErr.GetDetail()) + } + return fmt.Errorf("%s for %s: %w", operation, entityID, err) +} + +// WithContext adds context information to an error +func WithContext(err error, context string) error { + return fmt.Errorf("%s: %w", context, err) +} + +// NotFoundError represents a resource not found error +type NotFoundError struct { + Resource string + ID string +} + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("%s with ID '%s' not found", e.Resource, e.ID) +} + +// ExitWithError prints an error message with context and exits the program. +func ExitWithError(context string, err error) { + log.Fatalf("%s: %v", context, err) + // Note: log.Fatalf calls os.Exit(1) +} diff --git a/usage-examples/go/atlas-sdk-go/internal/logs/gzip.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/compress.go similarity index 73% rename from usage-examples/go/atlas-sdk-go/internal/logs/gzip.go rename to usage-examples/go/atlas-sdk-go/internal/fileutils/compress.go index 13901f6..464e260 100644 --- a/usage-examples/go/atlas-sdk-go/internal/logs/gzip.go +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/compress.go @@ -1,11 +1,9 @@ -package logs +package fileutils import ( "compress/gzip" "fmt" "os" - - "atlas-sdk-go/internal" ) // DecompressGzip opens a .gz file and unpacks to specified destination. @@ -14,21 +12,21 @@ func DecompressGzip(srcPath, destPath string) error { if err != nil { return fmt.Errorf("open %s: %w", srcPath, err) } - defer internal.SafeClose(srcFile) + defer SafeClose(srcFile) gzReader, err := gzip.NewReader(srcFile) if err != nil { return fmt.Errorf("gzip reader %s: %w", srcPath, err) } - defer internal.SafeClose(gzReader) + defer SafeClose(gzReader) destFile, err := os.Create(destPath) if err != nil { return fmt.Errorf("create %s: %w", destPath, err) } - defer internal.SafeClose(destFile) + defer SafeClose(destFile) - if err := internal.SafeCopy(destFile, gzReader); err != nil { + if err := SafeCopy(destFile, gzReader); err != nil { return fmt.Errorf("decompress to %s: %w", destPath, err) } return nil diff --git a/usage-examples/go/atlas-sdk-go/internal/logs/gzip_test.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/compress_test.go similarity index 98% rename from usage-examples/go/atlas-sdk-go/internal/logs/gzip_test.go rename to usage-examples/go/atlas-sdk-go/internal/fileutils/compress_test.go index 9cbee64..2d33913 100644 --- a/usage-examples/go/atlas-sdk-go/internal/logs/gzip_test.go +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/compress_test.go @@ -1,4 +1,4 @@ -package logs +package fileutils import ( "compress/gzip" diff --git a/usage-examples/go/atlas-sdk-go/internal/utils.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go similarity index 66% rename from usage-examples/go/atlas-sdk-go/internal/utils.go rename to usage-examples/go/atlas-sdk-go/internal/fileutils/io.go index 99b4225..558a36e 100644 --- a/usage-examples/go/atlas-sdk-go/internal/utils.go +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go @@ -1,21 +1,26 @@ -package internal +package fileutils import ( "fmt" "io" "log" - "os" // :remove: - "path/filepath" // :remove: - - "go.mongodb.org/atlas-sdk/v20250219001/admin" + "os" + "path/filepath" ) -// FormatAPIError formats an error returned by the Atlas API with additional context. -func FormatAPIError(operation string, params interface{}, err error) error { - if apiErr, ok := admin.AsError(err); ok && apiErr.GetDetail() != "" { - return fmt.Errorf("%s %v: %w: %s", operation, params, err, apiErr.GetDetail()) +// WriteToFile copies everything from r into a new file at path. +// It will create or truncate that file. +func WriteToFile(r io.Reader, path string) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create %s: %w", path, err) } - return fmt.Errorf("%s %v: %w", operation, params, err) + defer SafeClose(f) + + if err := SafeCopy(f, r); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil } // SafeClose closes c and logs a warning on error. diff --git a/usage-examples/go/atlas-sdk-go/internal/logs/file_test.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/io_test.go similarity index 97% rename from usage-examples/go/atlas-sdk-go/internal/logs/file_test.go rename to usage-examples/go/atlas-sdk-go/internal/fileutils/io_test.go index 8876d5a..91f0b63 100644 --- a/usage-examples/go/atlas-sdk-go/internal/logs/file_test.go +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/io_test.go @@ -1,4 +1,4 @@ -package logs +package fileutils import ( "os" diff --git a/usage-examples/go/atlas-sdk-go/internal/fileutils/paths.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/paths.go new file mode 100644 index 0000000..f710f05 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/paths.go @@ -0,0 +1,27 @@ +package fileutils + +import ( + "fmt" + "path/filepath" + "time" +) + +// GenerateOutputPath creates a file path based on the base directory, prefix, and optional extension. +func GenerateOutputPath(baseDir, prefix, extension string) (string, error) { + if baseDir == "" { + return "", fmt.Errorf("baseDir cannot be empty") + } + if prefix == "" { + return "", fmt.Errorf("prefix cannot be empty") + } + + timestamp := time.Now().Format("20060102") + var filename string + if extension == "" { + filename = fmt.Sprintf("%s_%s", prefix, timestamp) + } else { + filename = fmt.Sprintf("%s_%s.%s", prefix, timestamp, extension) + } + + return filepath.Join(baseDir, filename), nil +} diff --git a/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go b/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go index b525983..96a57fd 100644 --- a/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go +++ b/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go @@ -1,20 +1,24 @@ package logs import ( + "atlas-sdk-go/examples/internal" "context" + "fmt" "io" - "atlas-sdk-go/internal" - "go.mongodb.org/atlas-sdk/v20250219001/admin" ) // FetchHostLogs calls the Atlas SDK and returns the raw, compressed log stream. func FetchHostLogs(ctx context.Context, sdk admin.MonitoringAndLogsApi, p *admin.GetHostLogsApiParams) (io.ReadCloser, error) { req := sdk.GetHostLogs(ctx, p.GroupId, p.HostName, p.LogName) + rc, _, err := req.Execute() if err != nil { - return nil, internal.FormatAPIError("fetch logs", p.HostName, err) + return nil, internal.FormatError("fetch logs", p.HostName, err) + } + if rc == nil { + return nil, fmt.Errorf("no data returned for host %q, log %q", p.HostName, p.LogName) } return rc, nil } diff --git a/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go b/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go index 9afef96..50dba52 100644 --- a/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go +++ b/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go @@ -1,14 +1,13 @@ package logs import ( + "atlas-sdk-go/internal/fileutils" "context" "fmt" "io" "strings" "testing" - "atlas-sdk-go/internal" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "go.mongodb.org/atlas-sdk/v20250219001/admin" @@ -74,7 +73,7 @@ func TestFetchHostLogs_Unit(t *testing.T) { } require.NoError(t, err) - defer internal.SafeClose(rc) + defer fileutils.SafeClose(rc) data, err := io.ReadAll(rc) require.NoError(t, err) diff --git a/usage-examples/go/atlas-sdk-go/internal/logs/file.go b/usage-examples/go/atlas-sdk-go/internal/logs/file.go deleted file mode 100644 index b68f17f..0000000 --- a/usage-examples/go/atlas-sdk-go/internal/logs/file.go +++ /dev/null @@ -1,24 +0,0 @@ -package logs - -import ( - "fmt" - "io" - "os" - - "atlas-sdk-go/internal" -) - -// WriteToFile copies everything from r into a new file at path. -// It will create or truncate that file. -func WriteToFile(r io.Reader, path string) error { - f, err := os.Create(path) - if err != nil { - return fmt.Errorf("create %s: %w", path, err) - } - defer internal.SafeClose(f) - - if err := internal.SafeCopy(f, r); err != nil { - return fmt.Errorf("write %s: %w", path, err) - } - return nil -} diff --git a/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go b/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go index 2d8900c..5a2f032 100644 --- a/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go +++ b/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go @@ -1,11 +1,10 @@ package metrics import ( + "atlas-sdk-go/examples/internal" "context" "fmt" - "atlas-sdk-go/internal" - "go.mongodb.org/atlas-sdk/v20250219001/admin" ) @@ -16,11 +15,10 @@ func FetchDiskMetrics(ctx context.Context, sdk admin.MonitoringAndLogsApi, p *ad r, _, err := req.Execute() if err != nil { - return nil, internal.FormatAPIError("fetch disk metrics", p.PartitionName, err) + return nil, internal.FormatError("fetch disk metrics", p.PartitionName, err) } if r == nil || !r.HasMeasurements() || len(r.GetMeasurements()) == 0 { - return nil, fmt.Errorf("no metrics for partition %q on process %q", - p.PartitionName, p.ProcessId) + return nil, fmt.Errorf("no metrics for partition %q on process %q", p.PartitionName, p.ProcessId) } return r, nil } diff --git a/usage-examples/go/atlas-sdk-go/internal/metrics/process.go b/usage-examples/go/atlas-sdk-go/internal/metrics/process.go index feda72a..c0ff685 100644 --- a/usage-examples/go/atlas-sdk-go/internal/metrics/process.go +++ b/usage-examples/go/atlas-sdk-go/internal/metrics/process.go @@ -1,11 +1,10 @@ package metrics import ( + "atlas-sdk-go/examples/internal" "context" "fmt" - "atlas-sdk-go/internal" - "go.mongodb.org/atlas-sdk/v20250219001/admin" ) @@ -16,7 +15,7 @@ func FetchProcessMetrics(ctx context.Context, sdk admin.MonitoringAndLogsApi, p r, _, err := req.Execute() if err != nil { - return nil, internal.FormatAPIError("fetch process metrics", p.GroupId, err) + return nil, internal.FormatError("fetch process metrics", p.GroupId, err) } if r == nil || !r.HasMeasurements() || len(r.GetMeasurements()) == 0 { return nil, fmt.Errorf("no metrics for process %q", p.ProcessId) From d91af96e4c4fba15de61fe56ad10d66d0dbe06e3 Mon Sep 17 00:00:00 2001 From: cbullinger Date: Wed, 2 Jul 2025 13:24:08 -0400 Subject: [PATCH 03/11] Add new and updated tests --- usage-examples/go/atlas-sdk-go/README.md | 34 ++- .../examples/billing/historical/main.go | 67 +++-- .../examples/billing/line_items/main.go | 51 +++- .../examples/billing/linked_orgs/main.go | 20 +- .../logs}/main.go | 46 +-- .../examples/monitoring/metrics_disk/main.go | 85 ++++++ .../metrics_process}/main.go | 25 +- .../examples/monitoring_metrics_disk/main.go | 54 ---- .../go/atlas-sdk-go/internal/auth/client.go | 11 +- .../atlas-sdk-go/internal/auth/client_test.go | 54 ++++ .../internal/billing/collector.go | 33 +-- .../internal/billing/collector_test.go | 269 ++++++++++++++++++ .../atlas-sdk-go/internal/billing/invoices.go | 39 +-- .../internal/billing/invoices_test.go | 229 +++++++++++++++ .../internal/billing/linked_billing.go | 15 +- .../internal/billing/linkedorgs.go | 25 -- .../internal/billing/linkedorgs_test.go | 145 ---------- .../internal/billing/sku_classifier_test.go | 82 ++++++ .../internal/config/loadconfig.go | 41 ++- .../atlas-sdk-go/internal/config/loadenv.go | 6 +- .../internal/data/export/formats.go | 31 +- .../internal/data/export/formats_test.go | 176 ++++++++++++ .../go/atlas-sdk-go/internal/errors/utils.go | 19 +- .../go/atlas-sdk-go/internal/fileutils/io.go | 28 +- .../atlas-sdk-go/internal/fileutils/path.go | 40 +++ .../internal/fileutils/path_test.go | 90 ++++++ .../atlas-sdk-go/internal/fileutils/paths.go | 27 -- .../go/atlas-sdk-go/internal/logs/fetch.go | 16 +- .../atlas-sdk-go/internal/logs/fetch_test.go | 41 ++- .../go/atlas-sdk-go/internal/metrics/disk.go | 11 +- .../atlas-sdk-go/internal/metrics/process.go | 11 +- 31 files changed, 1374 insertions(+), 447 deletions(-) rename usage-examples/go/atlas-sdk-go/examples/{monitoring_logs => monitoring/logs}/main.go (64%) create mode 100644 usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_disk/main.go rename usage-examples/go/atlas-sdk-go/examples/{monitoring_metrics_process => monitoring/metrics_process}/main.go (70%) delete mode 100644 usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_disk/main.go create mode 100644 usage-examples/go/atlas-sdk-go/internal/auth/client_test.go create mode 100644 usage-examples/go/atlas-sdk-go/internal/billing/collector_test.go create mode 100644 usage-examples/go/atlas-sdk-go/internal/billing/invoices_test.go delete mode 100644 usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go delete mode 100644 usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs_test.go create mode 100644 usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier_test.go create mode 100644 usage-examples/go/atlas-sdk-go/internal/data/export/formats_test.go create mode 100644 usage-examples/go/atlas-sdk-go/internal/fileutils/path.go create mode 100644 usage-examples/go/atlas-sdk-go/internal/fileutils/path_test.go delete mode 100644 usage-examples/go/atlas-sdk-go/internal/fileutils/paths.go diff --git a/usage-examples/go/atlas-sdk-go/README.md b/usage-examples/go/atlas-sdk-go/README.md index 65d80bc..b17f5cb 100644 --- a/usage-examples/go/atlas-sdk-go/README.md +++ b/usage-examples/go/atlas-sdk-go/README.md @@ -14,7 +14,9 @@ Currently, the repository includes examples that demonstrate the following: - Authenticate with service accounts - Return cluster and database metrics - Download logs for a specific host +- Pull and parse line-item-level billing data - Return all linked organizations from a specific billing organization +- Get historical invoices for an organization - Programmatically manage Atlas resources As the Architecture Center documentation evolves, this repository will be updated with new examples @@ -24,20 +26,20 @@ and improvements to existing code. ```text . -├── cmd # Runnable examples by category -│ ├── get_linked_orgs/main.go -│ ├── get_logs/main.go -│ ├── get_metrics_disk/main.go -│ └── get_metrics_process/main.go +├── examples # Runnable examples by category +│ ├── billing/ +│ └── monitoring/ ├── configs # Atlas configuration template │ └── config.json ├── internal # Shared utilities and helpers │ ├── auth/ │ ├── billing/ │ ├── config/ +│ ├── data/ +│ ├── errors/ +│ ├── fileutils/ │ ├── logs/ -│ ├── metrics/ -│ └── utils.go +│ └── metrics/ ├── go.mod ├── go.sum ├── CHANGELOG.md # List of major changes to the project @@ -85,9 +87,17 @@ You can also adjust them to suit your needs: - Change output formats ### Billing -#### Get All Linked Organizations +#### Get Historical Invoices +```bash +go run examples/billing/historical/main.go +``` +#### Get Line-Item-Level Billing Data +```bash +go run examples/billing/line_items/main.go +``` +#### Get All Linked Organizations ```bash -go run cmd/get_linked_orgs/main.go +go run examples/billing/linked_orgs/main.go ``` ### Logs @@ -95,7 +105,7 @@ Logs output to `./logs` as `.gz` and `.txt`. #### Fetch All Host Logs ```bash -go run cmd/get_logs/main.go +go run examples/monitoring/logs/main.go ``` ### Metrics @@ -103,12 +113,12 @@ Metrics print to the console. #### Get Disk Measurements ```bash -go run cmd/get_metrics_disk/main.go +go run examples/monitoring/metrics_disk/main.go ``` #### Get Cluster Metrics ```bash -go run cmd/get_metrics_process/main.go +go run examples/monitoring/metrics_process/main.go ``` ## Changelog diff --git a/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go b/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go index 00c8103..78e2c03 100644 --- a/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go @@ -6,8 +6,10 @@ package main import ( "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/data/export" + "atlas-sdk-go/internal/errors" "atlas-sdk-go/internal/fileutils" "context" "fmt" @@ -15,14 +17,9 @@ import ( "time" "github.com/joho/godotenv" - - "atlas-sdk-go/internal/billing" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) -// :remove-start: -// TODO: QUESTION FOR REVIEWER: currently set to pull the past 6 months from current; do we want any additional configurations in this example? -// :remove-end: - func main() { if err := godotenv.Load(); err != nil { log.Printf("Warning: .env file not loaded: %v", err) @@ -30,54 +27,72 @@ func main() { secrets, cfg, err := config.LoadAll("configs/config.json") if err != nil { - log.Fatalf("config: failed to load file: %v", err) + errors.ExitWithError("Failed to load configuration", err) } client, err := auth.NewClient(cfg, secrets) if err != nil { - log.Fatalf("auth: failed client init: %v", err) + errors.ExitWithError("Failed to initialize authentication client", err) } ctx := context.Background() + p := &admin.ListInvoicesApiParams{ + OrgId: cfg.OrgID, + } - fmt.Printf("Fetching historical invoices for organization: %s\n", cfg.OrgID) + fmt.Printf("Fetching historical invoices for organization: %s\n", p.OrgId) - invoices, err := billing.ListInvoicesForOrg(ctx, client.InvoicesApi, cfg.OrgID, + // Fetch invoices from the previous six months with the provided options + invoices, err := billing.ListInvoicesForOrg(ctx, client.InvoicesApi, p, billing.WithViewLinkedInvoices(true), billing.WithIncludeCount(true), - billing.WithDateRange(time.Now().AddDate(0, -6, 0), time.Now())) // previous six months + billing.WithDateRange(time.Now().AddDate(0, -6, 0), time.Now())) if err != nil { - log.Fatalf("billing: cannot retrieve invoices: %v", err) + errors.ExitWithError("Failed to retrieve invoices", err) } if invoices.GetTotalCount() > 0 { fmt.Printf("Total count of invoices: %d\n", invoices.GetTotalCount()) } else { - fmt.Println("No invoices found for the specified date range.") + fmt.Println("No invoices found for the specified date range") return } - // Export invoice data to JSON and CSV file formats + // Export invoice data to be used in other systems or for reporting outDir := "invoices" - prefix := fmt.Sprintf("historical_%s", cfg.OrgID) + prefix := fmt.Sprintf("historical_%s", p.OrgId) + + exportInvoicesToJSON(invoices, outDir, prefix) + exportInvoicesToCSV(invoices, outDir, prefix) + // :remove-start: + // Clean up (internal-only function) + if err := fileutils.SafeDelete(outDir); err != nil { + log.Printf("Cleanup error: %v", err) + } + fmt.Println("Deleted generated files from", outDir) + // :remove-end: +} +func exportInvoicesToJSON(invoices *admin.PaginatedApiInvoiceMetadata, outDir, prefix string) { jsonPath, err := fileutils.GenerateOutputPath(outDir, prefix, "json") if err != nil { - log.Fatalf("common: generate output path: %v", err) + errors.ExitWithError("Failed to generate JSON output path", err) } if err := export.ToJSON(invoices.GetResults(), jsonPath); err != nil { - log.Fatalf("json: write file: %v", err) + errors.ExitWithError("Failed to write JSON file", err) } fmt.Printf("Exported invoice data to %s\n", jsonPath) +} +func exportInvoicesToCSV(invoices *admin.PaginatedApiInvoiceMetadata, outDir, prefix string) { csvPath, err := fileutils.GenerateOutputPath(outDir, prefix, "csv") if err != nil { - log.Fatalf("common: generate output path: %v", err) + errors.ExitWithError("Failed to generate CSV output path", err) } + // Set the headers and mapped rows for the CSV export headers := []string{"InvoiceID", "Status", "Created", "AmountBilled"} - - err = export.ToCSVWithMapper(invoices.GetResults(), csvPath, headers, func(invoice billing.InvoiceOption) []string { + err = export.ToCSVWithMapper(invoices.GetResults(), csvPath, headers, func(invoice admin.BillingInvoiceMetadata) []string { return []string{ invoice.GetId(), invoice.GetStatusName(), @@ -86,9 +101,19 @@ func main() { } }) if err != nil { - log.Fatalf("export: failed to write CSV file: %v", err) + errors.ExitWithError("Failed to write CSV file", err) } + fmt.Printf("Exported invoice data to %s\n", csvPath) } // :snippet-end: [historical-billing] +// :state-remove-start: copy +// NOTE: INTERNAL +// ** OUTPUT EXAMPLE ** +// +// Fetching historical invoices for organization: 5f7a9aec7d78fc03b42959328 +// Total count of invoices: 12 +// Exported invoice data to invoices/historical_5f7a9aec7d78fc03b42959328.json +// Exported invoice data to invoices/historical_5f7a9aec7d78fc03b42959328.csv +// :state-remove-end: [copy] diff --git a/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go b/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go index d020827..1a2110a 100644 --- a/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go @@ -5,15 +5,17 @@ package main import ( + "context" + "fmt" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + "log" + "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/data/export" "atlas-sdk-go/internal/errors" "atlas-sdk-go/internal/fileutils" - "context" - "fmt" - "log" "github.com/joho/godotenv" ) @@ -23,7 +25,7 @@ func main() { log.Printf("Warning: .env file not loaded: %v", err) } - secrets, cfg, err := config.LoadAll("configs/ignore.config.json") + secrets, cfg, err := config.LoadAll("configs/config.json") if err != nil { errors.ExitWithError("Failed to load configuration", err) } @@ -34,21 +36,35 @@ func main() { } ctx := context.Background() - OrgID := cfg.OrgID + p := &admin.ListInvoicesApiParams{ + OrgId: cfg.OrgID, + } - fmt.Printf("Fetching pending invoices for organization: %s\n", OrgID) + fmt.Printf("Fetching pending invoices for organization: %s\n", p.OrgId) - details, err := billing.CollectLineItemBillingData(ctx, client.InvoicesApi, client.OrganizationsApi, OrgID, nil) + details, err := billing.CollectLineItemBillingData(ctx, client.InvoicesApi, client.OrganizationsApi, p.OrgId, nil) if err != nil { - errors.ExitWithError("Failed to fetch billing data", err) + errors.ExitWithError(fmt.Sprintf("Failed to retrieve pending invoices for %s", p.OrgId), err) } fmt.Printf("Found %d line items in pending invoices\n", len(details)) + // Export invoice data to be used in other systems or for reporting outDir := "invoices" - prefix := fmt.Sprintf("pending_%s", OrgID) + prefix := fmt.Sprintf("pending_%s", p.OrgId) + + exportInvoicesToJSON(details, outDir, prefix) + exportInvoicesToCSV(details, outDir, prefix) + // :remove-start: + // Clean up (internal-only function) + if err := fileutils.SafeDelete(outDir); err != nil { + log.Printf("Cleanup error: %v", err) + } + fmt.Println("Deleted generated files from", outDir) + // :remove-end: +} - // Export to JSON +func exportInvoicesToJSON(details []billing.Detail, outDir, prefix string) { jsonPath, err := fileutils.GenerateOutputPath(outDir, prefix, "json") if err != nil { errors.ExitWithError("Failed to generate JSON output path", err) @@ -58,16 +74,17 @@ func main() { errors.ExitWithError("Failed to write JSON file", err) } fmt.Printf("Exported billing data to %s\n", jsonPath) +} - // Export to CSV file +func exportInvoicesToCSV(details []billing.Detail, outDir, prefix string) { csvPath, err := fileutils.GenerateOutputPath(outDir, prefix, "csv") if err != nil { errors.ExitWithError("Failed to generate CSV output path", err) } + // Set the headers and mapped rows for the CSV export headers := []string{"Organization", "OrgID", "Project", "ProjectID", "Cluster", "SKU", "Cost", "Date", "Provider", "Instance", "Category"} - err = export.ToCSVWithMapper(details, csvPath, headers, func(item billing.Detail) []string { return []string{ item.Org.Name, @@ -90,3 +107,13 @@ func main() { } // :snippet-end: [line-items] +// :state-remove-start: copy +// NOTE: INTERNAL +// ** OUTPUT EXAMPLE ** +// +// Fetching pending invoices for organization: 5f7a9ec7d78fc03b42959328 +// +// Found 3 line items in pending invoices +// Exported billing data to invoices/pending_5f7a9ec7d78fc03b42959328.json +// Exported billing data to invoices/pending_5f7a9ec7d78fc03b42959328.csv +// :state-remove-end: [copy] diff --git a/usage-examples/go/atlas-sdk-go/examples/billing/linked_orgs/main.go b/usage-examples/go/atlas-sdk-go/examples/billing/linked_orgs/main.go index 4848520..316c8d3 100644 --- a/usage-examples/go/atlas-sdk-go/examples/billing/linked_orgs/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/billing/linked_orgs/main.go @@ -5,13 +5,14 @@ package main import ( + "context" + "fmt" + "log" + "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/errors" - "context" - "fmt" - "log" "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" @@ -33,16 +34,18 @@ func main() { } ctx := context.Background() - orgID := cfg.OrgID + p := &admin.ListInvoicesApiParams{ + OrgId: cfg.OrgID, + } - fmt.Printf("Fetching linked organizations for billing organization: %s\n", orgID) + fmt.Printf("Fetching linked organizations for billing organization: %s\n", p.OrgId) - invoices, err := billing.GetCrossOrgBilling(ctx, client.InvoicesApi, orgID) + invoices, err := billing.GetCrossOrgBilling(ctx, client.InvoicesApi, p) if err != nil { - errors.ExitWithError(fmt.Sprintf("Failed to retrieve cross-organization billing data for %s", orgID), err) + errors.ExitWithError(fmt.Sprintf("Failed to retrieve cross-organization billing data for %s", p.OrgId), err) } - displayLinkedOrganizations(invoices, orgID) + displayLinkedOrganizations(invoices, p.OrgId) } func displayLinkedOrganizations(invoices map[string][]admin.BillingInvoiceMetadata, primaryOrgID string) { @@ -66,6 +69,7 @@ func displayLinkedOrganizations(invoices map[string][]admin.BillingInvoiceMetada // :snippet-end: [linked-billing] // :state-remove-start: copy +// NOTE: INTERNAL // ** OUTPUT EXAMPLE ** // // Fetching linked organizations for billing organization: 5f7a9ec7d78fc03b42959328 diff --git a/usage-examples/go/atlas-sdk-go/examples/monitoring_logs/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring/logs/main.go similarity index 64% rename from usage-examples/go/atlas-sdk-go/examples/monitoring_logs/main.go rename to usage-examples/go/atlas-sdk-go/examples/monitoring/logs/main.go index 117f52e..4ec061c 100644 --- a/usage-examples/go/atlas-sdk-go/examples/monitoring_logs/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/monitoring/logs/main.go @@ -5,68 +5,74 @@ package main import ( - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/config" "context" "fmt" "log" - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" "atlas-sdk-go/internal/fileutils" "atlas-sdk-go/internal/logs" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) func main() { if err := godotenv.Load(); err != nil { log.Printf("Warning: .env file not loaded: %v", err) } + secrets, cfg, err := config.LoadAll("configs/config.json") if err != nil { - log.Fatalf("config: failed to load file: %v", err) + errors.ExitWithError("Failed to load configuration", err) } - sdk, err := auth.NewClient(cfg, secrets) + client, err := auth.NewClient(cfg, secrets) if err != nil { - log.Fatalf("auth: failed client init: %v", err) + errors.ExitWithError("Failed to initialize authentication client", err) } ctx := context.Background() + + // Fetch logs with the provided parameters p := &admin.GetHostLogsApiParams{ GroupId: cfg.ProjectID, HostName: cfg.HostName, LogName: "mongodb", } + rc, err := logs.FetchHostLogs(ctx, client.MonitoringAndLogsApi, p) + if err != nil { + errors.ExitWithError("Failed to fetch logs", err) + } + defer fileutils.SafeClose(rc) + // Prepare output paths outDir := "logs" prefix := fmt.Sprintf("%s_%s", p.HostName, p.LogName) gzPath, err := fileutils.GenerateOutputPath(outDir, prefix, "gz") if err != nil { - log.Fatalf("common: failed to generate output path: %v", err) + errors.ExitWithError("Failed to generate GZ output path", err) } - txtPath, err := fileutils.GenerateOutputPath(outDir, prefix, ".txt") + txtPath, err := fileutils.GenerateOutputPath(outDir, prefix, "txt") if err != nil { - log.Fatalf("common: failed to generate output path: %v", err) + errors.ExitWithError("Failed to generate TXT output path", err) } - rc, err := logs.FetchHostLogs(ctx, sdk.MonitoringAndLogsApi, p) - if err != nil { - log.Fatalf("logs: failed to fetch logs: %v", err) - } - defer fileutils.SafeClose(rc) - + // Save compressed logs if err := fileutils.WriteToFile(rc, gzPath); err != nil { - log.Fatalf("fileutils: failed to save gz: %v", err) + errors.ExitWithError("Failed to save compressed logs", err) } fmt.Println("Saved compressed log to", gzPath) + // Decompress logs if err := fileutils.DecompressGzip(gzPath, txtPath); err != nil { - log.Fatalf("fileutils: failed to decompress gz: %v", err) + errors.ExitWithError("Failed to decompress logs", err) } fmt.Println("Uncompressed log to", txtPath) // :remove-start: - // NOTE: Internal-only function to clean up any downloaded files + // Clean up (internal-only function) if err := fileutils.SafeDelete(outDir); err != nil { log.Printf("Cleanup error: %v", err) } diff --git a/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_disk/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_disk/main.go new file mode 100644 index 0000000..26f43ab --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_disk/main.go @@ -0,0 +1,85 @@ +// :snippet-start: get-metrics-dev +// :state-remove-start: copy +// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk +// :state-remove-end: [copy] +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/metrics" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + + // Fetch disk metrics with the provided parameters + p := &admin.GetDiskMeasurementsApiParams{ + GroupId: cfg.ProjectID, + ProcessId: cfg.ProcessID, + PartitionName: "data", + M: &[]string{"DISK_PARTITION_SPACE_FREE", "DISK_PARTITION_SPACE_USED"}, + Granularity: admin.PtrString("P1D"), + Period: admin.PtrString("P1D"), + } + view, err := metrics.FetchDiskMetrics(ctx, client.MonitoringAndLogsApi, p) + if err != nil { + errors.ExitWithError("Failed to fetch disk metrics", err) + } + + // Output metrics + out, err := json.MarshalIndent(view, "", " ") + if err != nil { + errors.ExitWithError("Failed to format metrics data", err) + } + fmt.Println(string(out)) +} + +// :snippet-end: [get-metrics-dev] +// :state-remove-start: [copy] +// NOTE: INTERNAL +// ** OUTPUT EXAMPLE ** +// { +// "measurements": [ +// { +// "name": "DISK_PARTITION_SPACE_FREE", +// "granularity": "P1D", +// "period": "P1D", +// "values": [ +// { +// "timestamp": "2023-10-01T00:00:00Z", +// "value": 1234567890 +// }, +// { +// "timestamp": "2023-10-02T00:00:00Z", +// "value": 1234567890 +// } +// ] +// }, +// ... +// ] +// } +// :state-remove-end: [copy] diff --git a/usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_process/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go similarity index 70% rename from usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_process/main.go rename to usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go index fbfd70e..f0a7f8c 100644 --- a/usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_process/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go @@ -5,13 +5,15 @@ package main import ( - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" "context" "encoding/json" "fmt" "log" + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" + "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" @@ -22,17 +24,20 @@ func main() { if err := godotenv.Load(); err != nil { log.Printf("Warning: .env file not loaded: %v", err) } + secrets, cfg, err := config.LoadAll("configs/config.json") if err != nil { - log.Fatalf("config: load config file: %v", err) + errors.ExitWithError("Failed to load configuration", err) } - sdk, err := auth.NewClient(cfg, secrets) + client, err := auth.NewClient(cfg, secrets) if err != nil { - log.Fatalf("auth: client init: %v", err) + errors.ExitWithError("Failed to initialize authentication client", err) } ctx := context.Background() + + // Fetch process metrics with the provided parameters p := &admin.GetHostMeasurementsApiParams{ GroupId: cfg.ProjectID, ProcessId: cfg.ProcessID, @@ -47,12 +52,16 @@ func main() { Period: admin.PtrString("P7D"), } - view, err := metrics.FetchProcessMetrics(ctx, sdk.MonitoringAndLogsApi, p) + view, err := metrics.FetchProcessMetrics(ctx, client.MonitoringAndLogsApi, p) if err != nil { - log.Fatalf("metrics: fetch process metrics: %v", err) + errors.ExitWithError("Failed to fetch process metrics", err) } - out, _ := json.MarshalIndent(view, "", " ") + // Output metrics + out, err := json.MarshalIndent(view, "", " ") + if err != nil { + errors.ExitWithError("Failed to format metrics data", err) + } fmt.Println(string(out)) } diff --git a/usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_disk/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_disk/main.go deleted file mode 100644 index a86bcd5..0000000 --- a/usage-examples/go/atlas-sdk-go/examples/monitoring_metrics_disk/main.go +++ /dev/null @@ -1,54 +0,0 @@ -// :snippet-start: get-metrics-dev -// :state-remove-start: copy -// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk -// :state-remove-end: [copy] -package main - -import ( - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/config" - "context" - "encoding/json" - "fmt" - "log" - - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - - "atlas-sdk-go/internal/metrics" -) - -func main() { - if err := godotenv.Load(); err != nil { - log.Printf("Warning: .env file not loaded: %v", err) - } - secrets, cfg, err := config.LoadAll("configs/config.json") - if err != nil { - log.Fatalf("config: load config file: %v", err) - } - - sdk, err := auth.NewClient(cfg, secrets) - if err != nil { - log.Fatalf("auth: client init: %v", err) - } - - ctx := context.Background() - p := &admin.GetDiskMeasurementsApiParams{ - GroupId: cfg.ProjectID, - ProcessId: cfg.ProcessID, - PartitionName: "data", - M: &[]string{"DISK_PARTITION_SPACE_FREE", "DISK_PARTITION_SPACE_USED"}, - Granularity: admin.PtrString("P1D"), - Period: admin.PtrString("P1D"), - } - - view, err := metrics.FetchDiskMetrics(ctx, sdk.MonitoringAndLogsApi, p) - if err != nil { - log.Fatalf("metrics: fetch disk metrics: %v", err) - } - - out, _ := json.MarshalIndent(view, "", " ") - fmt.Println(string(out)) -} - -// :snippet-end: [get-metrics-dev] diff --git a/usage-examples/go/atlas-sdk-go/internal/auth/client.go b/usage-examples/go/atlas-sdk-go/internal/auth/client.go index 3af6f7d..a0b81ce 100644 --- a/usage-examples/go/atlas-sdk-go/internal/auth/client.go +++ b/usage-examples/go/atlas-sdk-go/internal/auth/client.go @@ -1,9 +1,10 @@ package auth import ( + "context" + "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/errors" - "context" "go.mongodb.org/atlas-sdk/v20250219001/admin" ) @@ -11,6 +12,14 @@ import ( // NewClient initializes and returns an authenticated Atlas API client using OAuth2 with service account credentials (recommended) // See: https://www.mongodb.com/docs/atlas/architecture/current/auth/#service-accounts func NewClient(cfg *config.Config, secrets *config.Secrets) (*admin.APIClient, error) { + if cfg == nil { + return nil, &errors.ValidationError{Message: "config cannot be nil"} + } + + if secrets == nil { + return nil, &errors.ValidationError{Message: "secrets cannot be nil"} + } + sdk, err := admin.NewClient( admin.UseBaseURL(cfg.BaseURL), admin.UseOAuthAuth(context.Background(), diff --git a/usage-examples/go/atlas-sdk-go/internal/auth/client_test.go b/usage-examples/go/atlas-sdk-go/internal/auth/client_test.go new file mode 100644 index 0000000..5bbdeee --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/auth/client_test.go @@ -0,0 +1,54 @@ +package auth_test + +import ( + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" + internalerrors "atlas-sdk-go/internal/errors" + "errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestNewClient_Success(t *testing.T) { + t.Parallel() + cfg := &config.Config{BaseURL: "https://example.com"} + secrets := &config.Secrets{ + ServiceAccountID: "validID", + ServiceAccountSecret: "validSecret", + } + + client, err := auth.NewClient(cfg, secrets) + + require.NoError(t, err) + require.NotNil(t, client) +} + +func TestNewClient_returnsErrorWhenConfigIsNil(t *testing.T) { + t.Parallel() + secrets := &config.Secrets{ + ServiceAccountID: "validID", + ServiceAccountSecret: "validSecret", + } + + client, err := auth.NewClient(nil, secrets) + + require.Error(t, err) + require.Nil(t, client) + var validationErr *internalerrors.ValidationError + require.True(t, errors.As(err, &validationErr), "expected error to be *errors.ValidationError") + assert.Equal(t, "config cannot be nil", validationErr.Message) +} + +func TestNewClient_returnsErrorWhenSecretsAreNil(t *testing.T) { + t.Parallel() + cfg := &config.Config{BaseURL: "https://example.com"} + + client, err := auth.NewClient(cfg, nil) + + require.Error(t, err) + require.Nil(t, client) + var validationErr *internalerrors.ValidationError + require.True(t, errors.As(err, &validationErr), "expected error to be *errors.ValidationError") + assert.Equal(t, "secrets cannot be nil", validationErr.Message) +} diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/collector.go b/usage-examples/go/atlas-sdk-go/internal/billing/collector.go index 193ed83..c17750d 100644 --- a/usage-examples/go/atlas-sdk-go/internal/billing/collector.go +++ b/usage-examples/go/atlas-sdk-go/internal/billing/collector.go @@ -1,11 +1,12 @@ package billing import ( - "atlas-sdk-go/internal/errors" "context" "fmt" "time" + "atlas-sdk-go/internal/errors" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) @@ -34,7 +35,9 @@ type ProjectInfo struct { Name string `json:"name"` } -// CollectLineItemBillingData fetches all pending invoices for the specified organization and extracts line items with tags +// CollectLineItemBillingData retrieves all pending invoices for the specified organization, +// transforms them into detailed billing records, and filters out items processed before lastProcessedDate. +// Returns a slice of billing Details or an error if no valid invoices or line items are found. func CollectLineItemBillingData(ctx context.Context, sdk admin.InvoicesApi, orgSdk admin.OrganizationsApi, orgID string, lastProcessedDate *time.Time) ([]Detail, error) { req := sdk.ListPendingInvoices(ctx, orgID) r, _, err := req.Execute() @@ -43,10 +46,7 @@ func CollectLineItemBillingData(ctx context.Context, sdk admin.InvoicesApi, orgS return nil, errors.FormatError("list pending invoices", orgID, err) } if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { - return nil, &errors.NotFoundError{ - Resource: "pending invoices", - ID: orgID, - } + return nil, &errors.NotFoundError{Resource: "pending invoices", ID: orgID} } fmt.Printf("Found %d pending invoice(s)\n", len(r.GetResults())) @@ -66,16 +66,16 @@ func CollectLineItemBillingData(ctx context.Context, sdk admin.InvoicesApi, orgS } if len(billingDetails) == 0 { - return nil, &errors.NotFoundError{ - Resource: "line items in pending invoices", - ID: orgID, - } + return nil, &errors.NotFoundError{Resource: "line items in pending invoices", ID: orgID} } return billingDetails, nil } -// processInvoices extracts and transforms line items from invoices +// processInvoices extracts and transforms billing line items from invoices into Detail structs. +// The function iterates through all invoices and their line items, filters out items processed before +// lastProcessedDate (if provided), then determines line item details, such as organization and project, +// pricing, and SKU-based information. func processInvoices(invoices []admin.BillingInvoice, orgID, orgName string, lastProcessedDate *time.Time) ([]Detail, error) { var billingDetails []Detail @@ -83,15 +83,11 @@ func processInvoices(invoices []admin.BillingInvoice, orgID, orgName string, las fmt.Printf("Processing invoice ID: %s\n", invoice.GetId()) for _, lineItem := range invoice.GetLineItems() { - // Parse start date startDate := lineItem.GetStartDate() - - // Skip if older than last processed date if lastProcessedDate != nil && !startDate.After(*lastProcessedDate) { continue } - // Create transformed billing detail detail := Detail{ Org: OrgInfo{ ID: orgID, @@ -101,7 +97,7 @@ func processInvoices(invoices []admin.BillingInvoice, orgID, orgName string, las ID: lineItem.GetGroupId(), Name: lineItem.GetGroupName(), }, - Cluster: getValueOrDefault(lineItem.GetClusterName(), "--n/a--"), + Cluster: getValueOrDefault(lineItem.GetClusterName(), "N/A"), SKU: lineItem.GetSku(), Cost: float64(lineItem.GetTotalPriceCents()) / 100.0, Date: startDate, @@ -136,8 +132,3 @@ func getValueOrDefault(value string, defaultValue string) string { } return value } - -// VerifyDataCompleteness compares source and transformed data counts -func VerifyDataCompleteness(sourceCount, transformedCount int) bool { - return sourceCount == transformedCount -} diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/collector_test.go b/usage-examples/go/atlas-sdk-go/internal/billing/collector_test.go new file mode 100644 index 0000000..b3aca90 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/billing/collector_test.go @@ -0,0 +1,269 @@ +package billing + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + "go.mongodb.org/atlas-sdk/v20250219001/mockadmin" +) + +func TestCollectLineItemBillingData_Success(t *testing.T) { + t.Parallel() + ctx := context.Background() + orgID := "org123" + orgName := "Test Organization" + + // Create sample invoice data + startDate := time.Now().Add(-24 * time.Hour) + invoiceID := "inv_123" + projectID := "proj456" + projectName := "Test Project" + clusterName := "testCluster" + sku := "CLUSTER-XYZ" + + mockInvoice := admin.BillingInvoice{ + Id: &invoiceID, + LineItems: &[]admin.InvoiceLineItem{ + { + StartDate: admin.PtrTime(startDate), + GroupId: &projectID, + GroupName: &projectName, + ClusterName: &clusterName, + Sku: &sku, + TotalPriceCents: admin.PtrInt64(5000), + }, + }, + } + + mockResponse := &admin.PaginatedApiInvoice{ + Results: &[]admin.BillingInvoice{mockInvoice}, + } + + mockOrg := &admin.AtlasOrganization{ + Id: &orgID, + Name: orgName, + } + + // Setup mock invoice service + mockInvoiceSvc := mockadmin.NewInvoicesApi(t) + mockInvoiceSvc.EXPECT(). + ListPendingInvoices(mock.Anything, orgID). + Return(admin.ListPendingInvoicesApiRequest{ApiService: mockInvoiceSvc}).Once() + mockInvoiceSvc.EXPECT(). + ListPendingInvoicesExecute(mock.Anything). + Return(mockResponse, nil, nil).Once() + + // Setup mock org service + mockOrgSvc := mockadmin.NewOrganizationsApi(t) + mockOrgSvc.EXPECT(). + GetOrganization(mock.Anything, orgID). + Return(admin.GetOrganizationApiRequest{ApiService: mockOrgSvc}).Once() + mockOrgSvc.EXPECT(). + GetOrganizationExecute(mock.Anything). + Return(mockOrg, nil, nil).Once() + + // Test execution + lastProcessedDate := time.Now().Add(-48 * time.Hour) // Older than the invoice + result, err := CollectLineItemBillingData(ctx, mockInvoiceSvc, mockOrgSvc, orgID, &lastProcessedDate) + + // Assertions + require.NoError(t, err) + require.Len(t, result, 1) + detail := result[0] + assert.Equal(t, orgID, detail.Org.ID) + assert.Equal(t, orgName, detail.Org.Name) + assert.Equal(t, projectID, detail.Project.ID) + assert.Equal(t, projectName, detail.Project.Name) + assert.Equal(t, clusterName, detail.Cluster) + assert.Equal(t, sku, detail.SKU) + assert.Equal(t, 50.0, detail.Cost) // 5000 cents = $50.00 + assert.Equal(t, startDate, detail.Date) +} + +func TestCollectLineItemBillingData_ApiError(t *testing.T) { + t.Parallel() + ctx := context.Background() + orgID := "org123" + + // Setup mock invoice service with error + mockInvoiceSvc := mockadmin.NewInvoicesApi(t) + mockInvoiceSvc.EXPECT(). + ListPendingInvoices(mock.Anything, orgID). + Return(admin.ListPendingInvoicesApiRequest{ApiService: mockInvoiceSvc}).Once() + mockInvoiceSvc.EXPECT(). + ListPendingInvoicesExecute(mock.Anything). + Return(nil, nil, assert.AnError).Once() + + mockOrgSvc := mockadmin.NewOrganizationsApi(t) + + // Test execution + result, err := CollectLineItemBillingData(ctx, mockInvoiceSvc, mockOrgSvc, orgID, nil) + + // Assertions + require.Error(t, err) + assert.Contains(t, err.Error(), "list pending invoices") + assert.Nil(t, result) +} + +func TestCollectLineItemBillingData_NoInvoices(t *testing.T) { + t.Parallel() + ctx := context.Background() + orgID := "org123" + + // Create empty response + mockResponse := &admin.PaginatedApiInvoice{ + Results: &[]admin.BillingInvoice{}, + } + + // Setup mock invoice service + mockInvoiceSvc := mockadmin.NewInvoicesApi(t) + mockInvoiceSvc.EXPECT(). + ListPendingInvoices(mock.Anything, orgID). + Return(admin.ListPendingInvoicesApiRequest{ApiService: mockInvoiceSvc}).Once() + mockInvoiceSvc.EXPECT(). + ListPendingInvoicesExecute(mock.Anything). + Return(mockResponse, nil, nil).Once() + + mockOrgSvc := mockadmin.NewOrganizationsApi(t) + + // Test execution + result, err := CollectLineItemBillingData(ctx, mockInvoiceSvc, mockOrgSvc, orgID, nil) + + // Assertions + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.Nil(t, result) +} + +func TestCollectLineItemBillingData_OrgLookupFails(t *testing.T) { + t.Parallel() + ctx := context.Background() + orgID := "org123" + + // Create sample invoice data + startDate := time.Now().Add(-24 * time.Hour) + invoiceID := "inv_123" + projectID := "proj456" + projectName := "Test Project" + clusterName := "testCluster" + sku := "CLUSTER-XYZ" + + mockInvoice := admin.BillingInvoice{ + Id: &invoiceID, + LineItems: &[]admin.InvoiceLineItem{ + { + StartDate: admin.PtrTime(startDate), + GroupId: &projectID, + GroupName: &projectName, + ClusterName: &clusterName, + Sku: &sku, + TotalPriceCents: admin.PtrInt64(5000), + }, + }, + } + + mockResponse := &admin.PaginatedApiInvoice{ + Results: &[]admin.BillingInvoice{mockInvoice}, + } + + // Setup mock services + mockInvoiceSvc := mockadmin.NewInvoicesApi(t) + mockInvoiceSvc.EXPECT(). + ListPendingInvoices(mock.Anything, orgID). + Return(admin.ListPendingInvoicesApiRequest{ApiService: mockInvoiceSvc}).Once() + mockInvoiceSvc.EXPECT(). + ListPendingInvoicesExecute(mock.Anything). + Return(mockResponse, nil, nil).Once() + + mockOrgSvc := mockadmin.NewOrganizationsApi(t) + mockOrgSvc.EXPECT(). + GetOrganization(mock.Anything, orgID). + Return(admin.GetOrganizationApiRequest{ApiService: mockOrgSvc}).Once() + mockOrgSvc.EXPECT(). + GetOrganizationExecute(mock.Anything). + Return(nil, nil, assert.AnError).Once() + + // Test execution + result, err := CollectLineItemBillingData(ctx, mockInvoiceSvc, mockOrgSvc, orgID, nil) + + // Assertions + require.NoError(t, err) // Org lookup failure is non-fatal + require.Len(t, result, 1) + assert.Equal(t, orgID, result[0].Org.Name) // Falls back to using orgID as name +} + +func TestCollectLineItemBillingData_FiltersByDate(t *testing.T) { + t.Parallel() + ctx := context.Background() + orgID := "org123" + orgName := "Test Organization" + + // Create invoice with items before and after cutoff + startDateOld := time.Now().Add(-48 * time.Hour) + startDateNew := time.Now().Add(-24 * time.Hour) + invoiceID := "inv_123" + projectID := "proj456" + projectName := "Test Project" + sku := "CLUSTER-XYZ" + + mockInvoice := admin.BillingInvoice{ + Id: &invoiceID, + LineItems: &[]admin.InvoiceLineItem{ + { + StartDate: admin.PtrTime(startDateOld), + GroupId: &projectID, + GroupName: &projectName, + Sku: &sku, + TotalPriceCents: admin.PtrInt64(5000), + }, + { + StartDate: admin.PtrTime(startDateNew), + GroupId: &projectID, + GroupName: &projectName, + Sku: &sku, + TotalPriceCents: admin.PtrInt64(3000), + }, + }, + } + + mockResponse := &admin.PaginatedApiInvoice{ + Results: &[]admin.BillingInvoice{mockInvoice}, + } + + mockOrg := &admin.AtlasOrganization{ + Id: &orgID, + Name: orgName, + } + + // Setup mock services + mockInvoiceSvc := mockadmin.NewInvoicesApi(t) + mockInvoiceSvc.EXPECT(). + ListPendingInvoices(mock.Anything, orgID). + Return(admin.ListPendingInvoicesApiRequest{ApiService: mockInvoiceSvc}).Once() + mockInvoiceSvc.EXPECT(). + ListPendingInvoicesExecute(mock.Anything). + Return(mockResponse, nil, nil).Once() + + mockOrgSvc := mockadmin.NewOrganizationsApi(t) + mockOrgSvc.EXPECT(). + GetOrganization(mock.Anything, orgID). + Return(admin.GetOrganizationApiRequest{ApiService: mockOrgSvc}).Once() + mockOrgSvc.EXPECT(). + GetOrganizationExecute(mock.Anything). + Return(mockOrg, nil, nil).Once() + + // Test with cutoff between the two dates + cutoffDate := startDateOld.Add(12 * time.Hour) + result, err := CollectLineItemBillingData(ctx, mockInvoiceSvc, mockOrgSvc, orgID, &cutoffDate) + + // Assertions + require.NoError(t, err) + require.Len(t, result, 1, "Should only include the newer line item") + assert.Equal(t, startDateNew, result[0].Date) + assert.Equal(t, 30.0, result[0].Cost) // 3000 cents = $30.00 +} diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/invoices.go b/usage-examples/go/atlas-sdk-go/internal/billing/invoices.go index c963bac..1b0fb2d 100644 --- a/usage-examples/go/atlas-sdk-go/internal/billing/invoices.go +++ b/usage-examples/go/atlas-sdk-go/internal/billing/invoices.go @@ -1,45 +1,46 @@ package billing import ( - "atlas-sdk-go/internal/errors" "context" "time" + "atlas-sdk-go/internal/errors" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) -// InvoiceOption defines a function type that modifies the parameters for listing invoices. +// InvoiceOption defines a function type that modifies the parameters for listing invoices type InvoiceOption func(*admin.ListInvoicesApiParams) -// WithIncludeCount sets the optional includeCount parameter (default: true). +// WithIncludeCount sets the optional includeCount parameter (default: true) func WithIncludeCount(includeCount bool) InvoiceOption { return func(p *admin.ListInvoicesApiParams) { p.IncludeCount = &includeCount } } -// WithItemsPerPage sets the optional itemsPerPage parameter (default: 100). +// WithItemsPerPage sets the optional itemsPerPage parameter (default: 100) func WithItemsPerPage(itemsPerPage int) InvoiceOption { return func(p *admin.ListInvoicesApiParams) { p.ItemsPerPage = &itemsPerPage } } -// WithPageNum sets the optional pageNum parameter (default: 1). +// WithPageNum sets the optional pageNum parameter (default: 1) func WithPageNum(pageNum int) InvoiceOption { return func(p *admin.ListInvoicesApiParams) { p.PageNum = &pageNum } } -// WithViewLinkedInvoices sets the optional viewLinkedInvoices parameter (default: true). +// WithViewLinkedInvoices sets the optional viewLinkedInvoices parameter (default: true) func WithViewLinkedInvoices(viewLinkedInvoices bool) InvoiceOption { return func(p *admin.ListInvoicesApiParams) { p.ViewLinkedInvoices = &viewLinkedInvoices } } -// WithStatusNames sets the optional statusNames parameter (default: include all statuses). +// WithStatusNames sets the optional statusNames parameter (default: include all statuses) // Possible status names: "PENDING" "CLOSED" "FORGIVEN" "FAILED" "PAID" "FREE" "PREPAID" "INVOICED" func WithStatusNames(statusNames []string) InvoiceOption { return func(p *admin.ListInvoicesApiParams) { @@ -47,7 +48,7 @@ func WithStatusNames(statusNames []string) InvoiceOption { } } -// WithDateRange sets the optional fromDate and toDate parameters (default: all possible dates). +// WithDateRange sets the optional fromDate and toDate parameters (default: earliest valid start to latest valid end) func WithDateRange(fromDate, toDate time.Time) InvoiceOption { return func(p *admin.ListInvoicesApiParams) { from := fromDate.Format(time.DateOnly) // Format to "YYYY-MM-DD" string @@ -57,33 +58,33 @@ func WithDateRange(fromDate, toDate time.Time) InvoiceOption { } } -// WithSortBy sets the optional sortBy parameter (default: "END_DATE"). +// WithSortBy sets the optional sortBy parameter (default: "END_DATE") func WithSortBy(sortBy string) InvoiceOption { return func(p *admin.ListInvoicesApiParams) { p.SortBy = &sortBy } } -// WithOrderBy sets the optional orderBy parameter (default: "desc"). +// WithOrderBy sets the optional orderBy parameter (default: "desc") func WithOrderBy(orderBy string) InvoiceOption { return func(p *admin.ListInvoicesApiParams) { p.OrderBy = &orderBy } } -// ListInvoicesForOrg returns all eligible invoices for the given organization, +// ListInvoicesForOrg returns all eligible invoices for the given Atlas organization, // including linked organizations when cross-organization billing is enabled. -// It accepts a context for the request, an InvoicesApi client instance, the ID of the +// Accepts a context for the request, an InvoicesApi client instance, the ID of the // organization to retrieve invoices for, and optional query parameters. -// It returns the invoice results or an error if the invoice retrieval fails. +// Returns the invoice results or an error if the operation fails. // Use options to customize pagination, filtering, and sorting (see With* functions). // // Required Permissions: -// - Organization Billing Viewer role can view invoices for the organization. -// - Organization Billing Admin or Organization Owner role can view invoices and linked invoices for the organization. -func ListInvoicesForOrg(ctx context.Context, sdk admin.InvoicesApi, orgID string, opts ...InvoiceOption) (*admin.PaginatedApiInvoiceMetadata, error) { +// - Organization Billing Viewer role can view invoices for the organization +// - Organization Billing Admin or Organization Owner role can view invoices and linked invoices for the organization +func ListInvoicesForOrg(ctx context.Context, sdk admin.InvoicesApi, p *admin.ListInvoicesApiParams, opts ...InvoiceOption) (*admin.PaginatedApiInvoiceMetadata, error) { params := &admin.ListInvoicesApiParams{ - OrgId: orgID, + OrgId: p.OrgId, } for _, opt := range opts { @@ -122,10 +123,10 @@ func ListInvoicesForOrg(ctx context.Context, sdk admin.InvoicesApi, orgID string r, _, err := req.Execute() if err != nil { - return nil, errors.FormatError("list invoices", orgID, err) + return nil, errors.FormatError("list invoices", p.OrgId, err) } if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { - return nil, &errors.NotFoundError{Resource: "Invoices", ID: orgID} + return nil, &errors.NotFoundError{Resource: "Invoices", ID: p.OrgId} } return r, nil } diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/invoices_test.go b/usage-examples/go/atlas-sdk-go/internal/billing/invoices_test.go new file mode 100644 index 0000000..0960c6f --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/billing/invoices_test.go @@ -0,0 +1,229 @@ +package billing + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + "go.mongodb.org/atlas-sdk/v20250219001/mockadmin" +) + +func TestListInvoicesForOrg_Success(t *testing.T) { + t.Parallel() + ctx := context.Background() + orgID := "org123" + invoiceID := "inv_123" + + mockResponse := &admin.PaginatedApiInvoiceMetadata{ + Results: &[]admin.BillingInvoiceMetadata{ + { + Id: &invoiceID, + OrgId: &orgID, + }, + }, + } + + mockSvc := mockadmin.NewInvoicesApi(t) + mockSvc.EXPECT(). + ListInvoices(mock.Anything, orgID). + Return(admin.ListInvoicesApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT(). + ListInvoicesExecute(mock.Anything). + Return(mockResponse, nil, nil).Once() + params := &admin.ListInvoicesApiParams{OrgId: orgID} + result, err := ListInvoicesForOrg(ctx, mockSvc, params) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, *result.Results, 1, "Should return one invoice") + assert.Equal(t, invoiceID, *(*result.Results)[0].Id, "Invoice ID should match expected value") +} + +func TestListInvoicesForOrg_ApiError(t *testing.T) { + t.Parallel() + ctx := context.Background() + orgID := "org123" + + mockSvc := mockadmin.NewInvoicesApi(t) + mockSvc.EXPECT(). + ListInvoices(mock.Anything, orgID). + Return(admin.ListInvoicesApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT(). + ListInvoicesExecute(mock.Anything). + Return(nil, nil, assert.AnError).Once() + + params := &admin.ListInvoicesApiParams{OrgId: orgID} + result, err := ListInvoicesForOrg(ctx, mockSvc, params) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "list invoices", "Should return formatted error when API call fails") +} + +func TestListInvoicesForOrg_NoInvoices(t *testing.T) { + t.Parallel() + ctx := context.Background() + orgID := "org123" + + // Create empty mock response + mockResponse := &admin.PaginatedApiInvoiceMetadata{ + Results: &[]admin.BillingInvoiceMetadata{}, + } + + mockSvc := mockadmin.NewInvoicesApi(t) + mockSvc.EXPECT(). + ListInvoices(mock.Anything, orgID). + Return(admin.ListInvoicesApiRequest{ApiService: mockSvc}).Once() + mockSvc.EXPECT(). + ListInvoicesExecute(mock.Anything). + Return(mockResponse, nil, nil).Once() + + params := &admin.ListInvoicesApiParams{OrgId: orgID} + result, err := ListInvoicesForOrg(ctx, mockSvc, params) + + require.Error(t, err) + require.Nil(t, result) + assert.Contains(t, err.Error(), "not found", "Should return NotFoundError when no invoices exist") +} + +func TestListInvoicesForOrg_WithEachOption(t *testing.T) { + t.Parallel() + orgID := "org123" + + // Apply each With* function and check if corresponding non-default param is set correctly + t.Run("WithIncludeCount", func(t *testing.T) { + params := &admin.ListInvoicesApiParams{OrgId: orgID} + includeCount := false // (default: true) + opt := WithIncludeCount(includeCount) + opt(params) + require.NotNil(t, params.IncludeCount) + assert.Equal(t, includeCount, *params.IncludeCount, "IncludeCount should be set to false") + }) + + t.Run("WithItemsPerPage", func(t *testing.T) { + params := &admin.ListInvoicesApiParams{OrgId: orgID} + itemsPerPage := 50 // (default: 100) + opt := WithItemsPerPage(itemsPerPage) + opt(params) + require.NotNil(t, params.ItemsPerPage) + assert.Equal(t, itemsPerPage, *params.ItemsPerPage, "ItemsPerPage should be set to 50") + }) + + t.Run("WithPageNum", func(t *testing.T) { + params := &admin.ListInvoicesApiParams{OrgId: orgID} + pageNum := 3 // (default: 1) + opt := WithPageNum(pageNum) + opt(params) + require.NotNil(t, params.PageNum) + assert.Equal(t, pageNum, *params.PageNum, "PageNum should be set to 3") + }) + + t.Run("WithViewLinkedInvoices", func(t *testing.T) { + params := &admin.ListInvoicesApiParams{OrgId: orgID} + viewLinkedInvoices := false // (default: true) + opt := WithViewLinkedInvoices(viewLinkedInvoices) + opt(params) + require.NotNil(t, params.ViewLinkedInvoices) + assert.Equal(t, viewLinkedInvoices, *params.ViewLinkedInvoices, "ViewLinkedInvoices should be set to false") + }) + + t.Run("WithStatusNames", func(t *testing.T) { + params := &admin.ListInvoicesApiParams{OrgId: orgID} + statusNames := []string{"PENDING", "CLOSED"} + opt := WithStatusNames(statusNames) // (default: all statuses) + opt(params) + require.NotNil(t, params.StatusNames) + assert.Equal(t, statusNames, *params.StatusNames, "StatusNames should be set to [PENDING CLOSED]") + }) + + t.Run("WithDateRange", func(t *testing.T) { + params := &admin.ListInvoicesApiParams{OrgId: orgID} + fromDate := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + toDate := time.Date(2023, 1, 31, 0, 0, 0, 0, time.UTC) + opt := WithDateRange(fromDate, toDate) + opt(params) + require.NotNil(t, params.FromDate) + require.NotNil(t, params.ToDate) + assert.Equal(t, "2023-01-01", *params.FromDate, "FromDate should be set to 2023-01-01") + assert.Equal(t, "2023-01-31", *params.ToDate, "ToDate should be set to 2023-01-31") + }) + + t.Run("WithSortBy", func(t *testing.T) { + params := &admin.ListInvoicesApiParams{OrgId: orgID} + sortBy := "CREATED_DATE" // (default: "END_DATE") + opt := WithSortBy(sortBy) + opt(params) + require.NotNil(t, params.SortBy) + assert.Equal(t, sortBy, *params.SortBy, "SortBy should be set to CREATED_DATE") + }) + + t.Run("WithOrderBy", func(t *testing.T) { + params := &admin.ListInvoicesApiParams{OrgId: orgID} + orderBy := "asc" // (default: "desc") + opt := WithOrderBy(orderBy) + opt(params) + require.NotNil(t, params.OrderBy) + assert.Equal(t, orderBy, *params.OrderBy, "OrderBy should be set to asc") + }) +} + +// TODO revisit this test to assert the options are applied correctly +func TestListInvoicesForOrg_WithCombinedOptions(t *testing.T) { + t.Parallel() + ctx := context.Background() + orgID := "org123" + + mockResponse := &admin.PaginatedApiInvoiceMetadata{ + Results: &[]admin.BillingInvoiceMetadata{ + {Id: admin.PtrString("inv_123")}, + {Id: admin.PtrString("inv_456")}, + {Id: admin.PtrString("inv_789")}, + {Id: admin.PtrString("inv_abc")}, + {Id: admin.PtrString("inv_def")}, + }, + } + + includeCount := false + itemsPerPage := 50 + pageNum := 2 + viewLinked := false + statusNames := []string{"PENDING", "CLOSED"} + sortBy := "CREATED_DATE" + orderBy := "asc" + + mockSvc := mockadmin.NewInvoicesApi(t) + + params := &admin.ListInvoicesApiParams{OrgId: orgID} + // Apply all options + options := []InvoiceOption{ + WithIncludeCount(includeCount), + WithItemsPerPage(itemsPerPage), + WithPageNum(pageNum), + WithViewLinkedInvoices(viewLinked), + WithStatusNames(statusNames), + WithDateRange( + time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2023, 1, 31, 0, 0, 0, 0, time.UTC), + ), + WithSortBy(sortBy), + WithOrderBy(orderBy), + } + // Mock the ListInvoices call to return our mockReq + mockSvc.EXPECT().ListInvoices(mock.Anything, orgID).Return(admin.ListInvoicesApiRequest{ + ApiService: mockSvc}).Once() + mockSvc.EXPECT(). + ListInvoicesExecute(mock.Anything). + Return(mockResponse, nil, nil).Once() + + result, err := ListInvoicesForOrg(ctx, mockSvc, params, options...) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Results) + require.Equal(t, mockResponse, result) + require.Len(t, *result.Results, 5, "Should return all five invoices from the mock response") +} diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/linked_billing.go b/usage-examples/go/atlas-sdk-go/internal/billing/linked_billing.go index 8aeeaf7..2c12337 100644 --- a/usage-examples/go/atlas-sdk-go/internal/billing/linked_billing.go +++ b/usage-examples/go/atlas-sdk-go/internal/billing/linked_billing.go @@ -1,9 +1,10 @@ package billing import ( - "atlas-sdk-go/internal/errors" "context" + "atlas-sdk-go/internal/errors" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) @@ -15,12 +16,12 @@ import ( // as values or an error if the invoice retrieval fails. // // Required Permissions: -// - Organization Billing Viewer role can view invoices for the organization. -// - Organization Billing Admin or Organization Owner role can view invoices and linked invoices for the organization. -func GetCrossOrgBilling(ctx context.Context, sdk admin.InvoicesApi, orgID string, opts ...InvoiceOption) (map[string][]admin.BillingInvoiceMetadata, error) { - r, err := ListInvoicesForOrg(ctx, sdk, orgID, opts...) +// - Organization Billing Viewer role can view invoices for the organization. +// - Organization Billing Admin or Organization Owner role can view invoices and linked invoices for the organization. +func GetCrossOrgBilling(ctx context.Context, sdk admin.InvoicesApi, p *admin.ListInvoicesApiParams, opts ...InvoiceOption) (map[string][]admin.BillingInvoiceMetadata, error) { + r, err := ListInvoicesForOrg(ctx, sdk, p, opts...) if err != nil { - return nil, errors.FormatError("get cross-organization billing", orgID, err) + return nil, errors.FormatError("get cross-organization billing", p.OrgId, err) } crossOrgBilling := make(map[string][]admin.BillingInvoiceMetadata) @@ -28,7 +29,7 @@ func GetCrossOrgBilling(ctx context.Context, sdk admin.InvoicesApi, orgID string return crossOrgBilling, nil } - crossOrgBilling[orgID] = r.GetResults() + crossOrgBilling[p.OrgId] = r.GetResults() for _, invoice := range r.GetResults() { if !invoice.HasLinkedInvoices() || len(invoice.GetLinkedInvoices()) == 0 { continue diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go b/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go deleted file mode 100644 index c57fd76..0000000 --- a/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs.go +++ /dev/null @@ -1,25 +0,0 @@ -package billing - -import ( - "context" - "fmt" - - "go.mongodb.org/atlas-sdk/v20250219001/admin" -) - -// ListLinkedOrgs returns all linked organizations for a given billing organization. -func ListLinkedOrgs(ctx context.Context, sdk admin.InvoicesApi, orgID string, opts ...InvoiceOption) ([]string, error) { - invoices, err := GetCrossOrgBilling(ctx, sdk, orgID, opts...) - if err != nil { - return nil, fmt.Errorf("get cross-org billing: %w", err) - } - - var linkedOrgs []string - for orgID := range invoices { - if orgID != orgID { - linkedOrgs = append(linkedOrgs, orgID) - } - } - - return linkedOrgs, nil -} diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs_test.go b/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs_test.go deleted file mode 100644 index 467797e..0000000 --- a/usage-examples/go/atlas-sdk-go/internal/billing/linkedorgs_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package billing_test - -import ( - "context" - "errors" - "sort" - "testing" - - "atlas-sdk-go/internal/billing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - "go.mongodb.org/atlas-sdk/v20250219001/mockadmin" -) - -func TestGetLinkedOrgs_Success(t *testing.T) { - billingOrgID := "billingOrgABC" - linkedOrgID1 := "linkedOrgABC" - linkedOrgID2 := "linkedOrgDEF" - invoiceID1 := "inv_123" - invoiceID2 := "inv_456" - - // Create mock response with linked invoices - mockResponse := &admin.PaginatedApiInvoiceMetadata{ - Results: &[]admin.BillingInvoiceMetadata{ - { - Id: &invoiceID1, - LinkedInvoices: &[]admin.BillingInvoiceMetadata{ - {OrgId: &linkedOrgID1, AmountBilledCents: admin.PtrInt64(1000)}, - }, - }, - { - Id: &invoiceID2, - LinkedInvoices: &[]admin.BillingInvoiceMetadata{ - {OrgId: &linkedOrgID1, AmountBilledCents: admin.PtrInt64(2000)}, - {OrgId: &linkedOrgID2, AmountBilledCents: admin.PtrInt64(500)}, - }, - }, - }, - } - - mockSvc := mockadmin.NewInvoicesApi(t) - mockSvc.EXPECT(). - ListInvoices(mock.Anything, billingOrgID). - Return(admin.ListInvoicesApiRequest{ApiService: mockSvc}).Once() - mockSvc.EXPECT(). - ListInvoicesExecute(mock.Anything). - Return(mockResponse, nil, nil).Once() - - params := &admin.ListInvoicesApiParams{OrgId: billingOrgID} - linkedOrgs, err := billing.ListLinkedOrgs(context.Background(), mockSvc, params) - - require.NoError(t, err) - assert.Len(t, linkedOrgs, 2, "Should return two linked organizations") - sort.Strings(linkedOrgs) - assert.Contains(t, linkedOrgs, linkedOrgID1, "Should contain linkedOrgID1") - assert.Contains(t, linkedOrgs, linkedOrgID2, "Should contain linkedOrgID2") -} - -func TestGetLinkedOrgs_ApiError(t *testing.T) { - billingOrgID := "billingOrgErr" - expectedError := errors.New("API error") - - mockSvc := mockadmin.NewInvoicesApi(t) - mockSvc.EXPECT(). - ListInvoices(mock.Anything, billingOrgID). - Return(admin.ListInvoicesApiRequest{ApiService: mockSvc}).Once() - mockSvc.EXPECT(). - ListInvoicesExecute(mock.Anything). - Return(nil, nil, expectedError).Once() - - params := &admin.ListInvoicesApiParams{OrgId: billingOrgID} - _, err := billing.ListLinkedOrgs(context.Background(), mockSvc, params) - - // Verify error handling - require.Error(t, err) - assert.Contains(t, err.Error(), "get cross-org billing") - assert.ErrorContains(t, err, expectedError.Error(), "Should return API error") -} - -func TestGetLinkedOrgs_NoLinkedOrgs(t *testing.T) { - billingOrgID := "billingOrg123" - invoiceID := "no_links" - - // Create mock response with no linked invoices - mockResponse := &admin.PaginatedApiInvoiceMetadata{ - Results: &[]admin.BillingInvoiceMetadata{ - { - Id: &invoiceID, - LinkedInvoices: &[]admin.BillingInvoiceMetadata{}, - }, - }, - } - - mockSvc := mockadmin.NewInvoicesApi(t) - mockSvc.EXPECT(). - ListInvoices(mock.Anything, billingOrgID). - Return(admin.ListInvoicesApiRequest{ApiService: mockSvc}).Once() - mockSvc.EXPECT(). - ListInvoicesExecute(mock.Anything). - Return(mockResponse, nil, nil).Once() - - params := &admin.ListInvoicesApiParams{OrgId: billingOrgID} - linkedOrgs, err := billing.ListLinkedOrgs(context.Background(), mockSvc, params) - - require.NoError(t, err) - assert.Empty(t, linkedOrgs, "Should return empty when no linked orgs exist") -} - -func TestGetLinkedOrgs_MissingOrgID(t *testing.T) { - billingOrgID := "billingOrg123" - linkedOrgID := "validOrgID" - invoiceID := "inv_missing_org" - - // Create mock response with one valid and one invalid linked invoice - mockResponse := &admin.PaginatedApiInvoiceMetadata{ - Results: &[]admin.BillingInvoiceMetadata{ - { - Id: &invoiceID, - LinkedInvoices: &[]admin.BillingInvoiceMetadata{ - {OrgId: nil, AmountBilledCents: admin.PtrInt64(500)}, - {OrgId: &linkedOrgID, AmountBilledCents: admin.PtrInt64(1000)}, - }, - }, - }, - } - - mockSvc := mockadmin.NewInvoicesApi(t) - mockSvc.EXPECT(). - ListInvoices(mock.Anything, billingOrgID). - Return(admin.ListInvoicesApiRequest{ApiService: mockSvc}).Once() - mockSvc.EXPECT(). - ListInvoicesExecute(mock.Anything). - Return(mockResponse, nil, nil).Once() - - // Run test - params := &admin.ListInvoicesApiParams{OrgId: billingOrgID} - linkedOrgs, err := billing.ListLinkedOrgs(context.Background(), mockSvc, params) - - require.NoError(t, err) - assert.Len(t, linkedOrgs, 1, "Should return one linked organization") - assert.Equal(t, linkedOrgID, linkedOrgs[0], "Should return the valid linked organization ID") -} diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier_test.go b/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier_test.go new file mode 100644 index 0000000..4e703bc --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier_test.go @@ -0,0 +1,82 @@ +package billing + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetermineProvider(t *testing.T) { + tests := []struct { + name string + sku string + expected string + }{ + {"AWS SKU", "MONGODB_ATLAS_AWS_INSTANCE_M10", "AWS"}, + {"AZURE SKU", "MONGODB_ATLAS_AZURE_INSTANCE_M20", "AZURE"}, + {"GCP SKU", "MONGODB_ATLAS_GCP_INSTANCE_M30", "GCP"}, + {"Unknown provider", "MONGODB_ATLAS_INSTANCE_M40", "n/a"}, + {"Empty SKU", "", "n/a"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := determineProvider(tc.sku) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestDetermineInstance(t *testing.T) { + tests := []struct { + name string + sku string + expected string + }{ + {"Basic instance", "MONGODB_ATLAS_AWS_INSTANCE_M10", "M10"}, + {"Complex instance name", "MONGODB_ATLAS_AWS_INSTANCE_M30_NVME", "M30_NVME"}, + {"No instance marker", "MONGODB_ATLAS_BACKUP", "non-instance"}, + {"Empty SKU", "", "non-instance"}, + {"Multiple instance markers", "INSTANCE_M10_INSTANCE_M20", "M20"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := determineInstance(tc.sku) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestDetermineCategory(t *testing.T) { + tests := []struct { + name string + sku string + expected string + }{ + {"Instance category", "MONGODB_ATLAS_AWS_INSTANCE_M10", "instances"}, + {"Backup category", "MONGODB_ATLAS_BACKUP", "backup"}, + {"PIT Restore", "MONGODB_ATLAS_PIT_RESTORE", "backup"}, + {"Data Transfer", "MONGODB_ATLAS_DATA_TRANSFER", "data xfer"}, + {"Storage", "MONGODB_ATLAS_STORAGE", "storage"}, + {"BI Connector", "MONGODB_ATLAS_BI_CONNECTOR", "bi-connector"}, + {"Data Lake", "MONGODB_ATLAS_DATA_LAKE", "data lake"}, + {"Auditing", "MONGODB_ATLAS_AUDITING", "audit"}, + {"Atlas Support", "MONGODB_ATLAS_SUPPORT", "support"}, + {"Free Support", "MONGODB_ATLAS_FREE_SUPPORT", "free support"}, + {"Charts", "MONGODB_ATLAS_CHARTS", "charts"}, + {"Serverless", "MONGODB_ATLAS_SERVERLESS", "serverless"}, + {"Security", "MONGODB_ATLAS_SECURITY", "security"}, + {"Private Endpoint", "MONGODB_ATLAS_PRIVATE_ENDPOINT", "private endpoint"}, + {"Other category", "MONGODB_ATLAS_UNKNOWN", "other"}, + {"Empty SKU", "", "other"}, + {"Multiple patterns", "MONGODB_ATLAS_BACKUP_STORAGE", "backup"}, // First match should win + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := determineCategory(tc.sku) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go b/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go index eba1a0f..a342512 100644 --- a/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go +++ b/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go @@ -1,11 +1,9 @@ package config import ( - "atlas-sdk-go/internal/fileutils" + "atlas-sdk-go/internal/errors" "encoding/json" - "fmt" "os" - "strings" ) type Config struct { @@ -17,31 +15,32 @@ type Config struct { ProcessID string `json:"ATLAS_PROCESS_ID"` } +// LoadConfig reads a JSON configuration file and returns a Config struct func LoadConfig(path string) (*Config, error) { - f, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open config %s: %w", path, err) + if path == "" { + return nil, &errors.ValidationError{ + Message: "configuration file path cannot be empty", + } } - defer fileutils.SafeClose(f) - var c Config - if err := json.NewDecoder(f).Decode(&c); err != nil { - return nil, fmt.Errorf("decode %s: %w", path, err) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, &errors.NotFoundError{Resource: "configuration file", ID: path} + } + return nil, errors.WithContext(err, "reading configuration file") } - if c.BaseURL == "" { - c.BaseURL = "https://cloud.mongodb.com" - } - if c.HostName == "" { - // Go 1.18+: - if host, _, ok := strings.Cut(c.ProcessID, ":"); ok { - c.HostName = host - } + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, errors.WithContext(err, "parsing configuration file") } - if c.OrgID == "" || c.ProjectID == "" { - return nil, fmt.Errorf("ATLAS_ORG_ID and ATLAS_PROJECT_ID are required") + if config.ProjectID == "" { + return nil, &errors.ValidationError{ + Message: "project ID is required in configuration", + } } - return &c, nil + return &config, nil } diff --git a/usage-examples/go/atlas-sdk-go/internal/config/loadenv.go b/usage-examples/go/atlas-sdk-go/internal/config/loadenv.go index 16dbeb2..aa002f6 100644 --- a/usage-examples/go/atlas-sdk-go/internal/config/loadenv.go +++ b/usage-examples/go/atlas-sdk-go/internal/config/loadenv.go @@ -1,7 +1,7 @@ package config import ( - "fmt" + "atlas-sdk-go/internal/errors" "os" "strings" ) @@ -32,7 +32,9 @@ func LoadSecrets() (*Secrets, error) { look(EnvSAClientSecret, &s.ServiceAccountSecret) if len(missing) > 0 { - return nil, fmt.Errorf("missing required env vars: %s", strings.Join(missing, ", ")) + return nil, &errors.ValidationError{ + Message: "missing required environment variables: " + strings.Join(missing, ", "), + } } return s, nil } diff --git a/usage-examples/go/atlas-sdk-go/internal/data/export/formats.go b/usage-examples/go/atlas-sdk-go/internal/data/export/formats.go index 2b30229..ca5fd5b 100644 --- a/usage-examples/go/atlas-sdk-go/internal/data/export/formats.go +++ b/usage-examples/go/atlas-sdk-go/internal/data/export/formats.go @@ -6,11 +6,12 @@ import ( "fmt" "io" "os" + "path/filepath" "atlas-sdk-go/internal/fileutils" ) -// ToJSON starts a goroutine to encode and write JSON data to a file +// ToJSON starts a goroutine to encode and write JSON data to a file at the given filePath func ToJSON(data interface{}, filePath string) error { if data == nil { return fmt.Errorf("data cannot be nil") @@ -19,6 +20,12 @@ func ToJSON(data interface{}, filePath string) error { return fmt.Errorf("filePath cannot be empty") } + // Create directory if it doesn't exist + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory for JSON export: %w", err) + } + pr, pw := io.Pipe() encodeErrCh := make(chan error, 1) @@ -44,8 +51,7 @@ func ToJSON(data interface{}, filePath string) error { return writeErr } -// ToCSV starts a goroutine to encode and write CSV data to a file -// ToCSV writes data in CSV format to a file +// ToCSV starts a goroutine to encode and write data in CSV format to a file at the given filePath func ToCSV(data [][]string, filePath string) error { if data == nil { return fmt.Errorf("data cannot be nil") @@ -53,6 +59,11 @@ func ToCSV(data [][]string, filePath string) error { if filePath == "" { return fmt.Errorf("filePath cannot be empty") } + // Create directory if it doesn't exist + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory for csv export: %w", err) + } file, err := os.Create(filePath) if err != nil { @@ -73,7 +84,18 @@ func ToCSV(data [][]string, filePath string) error { } // ToCSVWithMapper provides a generic method to convert domain objects to CSV data. -// It exports any slice of data to CSV with custom headers and row mapping +// It exports any slice of data to CSV with custom headers and row mapping (see below for example) +// +// headers := []string{"InvoiceID", "Status", "Created", "AmountBilled"} +// +// rowMapper := func(invoice billing.InvoiceOption) []string { +// return []string{ +// invoice.GetId(), +// invoice.GetStatusName(), +// invoice.GetCreated().Format(time.RFC3339), +// fmt.Sprintf("%.2f", float64(invoice.GetAmountBilledCents())/100.0), +// } +// } func ToCSVWithMapper[T any](data []T, filePath string, headers []string, rowMapper func(T) []string) error { if data == nil { return fmt.Errorf("data cannot be nil") @@ -85,7 +107,6 @@ func ToCSVWithMapper[T any](data []T, filePath string, headers []string, rowMapp return fmt.Errorf("rowMapper function cannot be nil") } - // Convert data to CSV format rows := make([][]string, 0, len(data)+1) rows = append(rows, headers) diff --git a/usage-examples/go/atlas-sdk-go/internal/data/export/formats_test.go b/usage-examples/go/atlas-sdk-go/internal/data/export/formats_test.go new file mode 100644 index 0000000..b5f4283 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/data/export/formats_test.go @@ -0,0 +1,176 @@ +package export + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type TestStruct struct { + ID int `json:"id"` + Name string `json:"name"` + Value float64 `json:"value"` +} + +func TestToJSON(t *testing.T) { + t.Parallel() + + t.Run("Successfully writes JSON to file", func(t *testing.T) { + // Setup + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "test.json") + testData := TestStruct{ID: 1, Name: "Test", Value: 99.99} + + // Execute + err := ToJSON(testData, filePath) + require.NoError(t, err) + + // Verify + fileContent, err := os.ReadFile(filePath) + require.NoError(t, err) + + var decoded TestStruct + err = json.Unmarshal(fileContent, &decoded) + require.NoError(t, err) + + assert.Equal(t, testData.ID, decoded.ID) + assert.Equal(t, testData.Name, decoded.Name) + assert.Equal(t, testData.Value, decoded.Value) + }) + + t.Run("Returns error for nil data", func(t *testing.T) { + err := ToJSON(nil, "test.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "data cannot be nil") + }) + + t.Run("Returns error for empty filepath", func(t *testing.T) { + err := ToJSON(TestStruct{}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "filePath cannot be empty") + }) + + t.Run("Creates directory structure if needed", func(t *testing.T) { + // Setup + tempDir := t.TempDir() + dirPath := filepath.Join(tempDir, "subdir1", "subdir2") + filePath := filepath.Join(dirPath, "test.json") + testData := TestStruct{ID: 1, Name: "Test", Value: 99.99} + + // Execute + err := ToJSON(testData, filePath) + require.NoError(t, err) + + // Verify directory was created + dirInfo, err := os.Stat(dirPath) + require.NoError(t, err) + assert.True(t, dirInfo.IsDir()) + }) +} + +func TestToCSV(t *testing.T) { + t.Parallel() + + t.Run("Successfully writes CSV to file", func(t *testing.T) { + // Setup + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "test.csv") + testData := [][]string{ + {"ID", "Name", "Value"}, + {"1", "Test", "99.99"}, + } + + // Execute + err := ToCSV(testData, filePath) + require.NoError(t, err) + + // Verify + file, err := os.Open(filePath) + require.NoError(t, err) + defer file.Close() + + reader := csv.NewReader(file) + rows, err := reader.ReadAll() + require.NoError(t, err) + + assert.Equal(t, testData, rows) + }) + + t.Run("Returns error for nil data", func(t *testing.T) { + err := ToCSV(nil, "test.csv") + require.Error(t, err) + assert.Contains(t, err.Error(), "data cannot be nil") + }) + + t.Run("Returns error for empty filepath", func(t *testing.T) { + err := ToCSV([][]string{{"test"}}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "filePath cannot be empty") + }) +} + +func TestToCSVWithMapper(t *testing.T) { + t.Parallel() + + t.Run("Successfully maps and writes data to CSV", func(t *testing.T) { + // Setup + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "test.csv") + testData := []TestStruct{ + {ID: 1, Name: "Test1", Value: 99.99}, + {ID: 2, Name: "Test2", Value: 199.99}, + } + headers := []string{"ID", "Name", "Value"} + rowMapper := func(item TestStruct) []string { + return []string{ + fmt.Sprintf("%d", item.ID), + item.Name, + fmt.Sprintf("%.2f", item.Value), + } + } + + // Execute + err := ToCSVWithMapper(testData, filePath, headers, rowMapper) + require.NoError(t, err) + + // Verify + file, err := os.Open(filePath) + require.NoError(t, err) + defer file.Close() + + reader := csv.NewReader(file) + rows, err := reader.ReadAll() + require.NoError(t, err) + + expectedRows := [][]string{ + {"ID", "Name", "Value"}, + {"1", "Test1", "99.99"}, + {"2", "Test2", "199.99"}, + } + assert.Equal(t, expectedRows, rows) + }) + + t.Run("Returns error for nil data", func(t *testing.T) { + err := ToCSVWithMapper[TestStruct](nil, "test.csv", []string{"header"}, func(t TestStruct) []string { return []string{} }) + require.Error(t, err) + assert.Contains(t, err.Error(), "data cannot be nil") + }) + + t.Run("Returns error for empty headers", func(t *testing.T) { + err := ToCSVWithMapper([]TestStruct{{ID: 1}}, "test.csv", []string{}, func(t TestStruct) []string { return []string{} }) + require.Error(t, err) + assert.Contains(t, err.Error(), "headers cannot be empty") + }) + + t.Run("Returns error for nil mapper function", func(t *testing.T) { + err := ToCSVWithMapper[TestStruct]([]TestStruct{{ID: 1}}, "test.csv", []string{"header"}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "rowMapper function cannot be nil") + }) +} diff --git a/usage-examples/go/atlas-sdk-go/internal/errors/utils.go b/usage-examples/go/atlas-sdk-go/internal/errors/utils.go index bc8fbdb..ca8130b 100644 --- a/usage-examples/go/atlas-sdk-go/internal/errors/utils.go +++ b/usage-examples/go/atlas-sdk-go/internal/errors/utils.go @@ -7,7 +7,7 @@ import ( "go.mongodb.org/atlas-sdk/v20250219001/admin" ) -// FormatError formats an error message for a specific operation and entity ID. +// FormatError formats an error message for a specific operation and entity ID func FormatError(operation string, entityID string, err error) error { if apiErr, ok := admin.AsError(err); ok && apiErr.GetDetail() != "" { return fmt.Errorf("%s for %s: %w: %s", operation, entityID, err, apiErr.GetDetail()) @@ -20,17 +20,28 @@ func WithContext(err error, context string) error { return fmt.Errorf("%s: %w", context, err) } -// NotFoundError represents a resource not found error +// ValidationError represents an error due to invalid input parameters +type ValidationError struct { + Message string +} + +// Error implements the ValidationError error interface +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation error: %s", e.Message) +} + +// NotFoundError represents an error when a requested resource cannot be found type NotFoundError struct { Resource string ID string } +// Error implements the error interface func (e *NotFoundError) Error() string { - return fmt.Sprintf("%s with ID '%s' not found", e.Resource, e.ID) + return fmt.Sprintf("resource not found: %s [%s]", e.Resource, e.ID) } -// ExitWithError prints an error message with context and exits the program. +// ExitWithError prints an error message with context and exits the program func ExitWithError(context string, err error) { log.Fatalf("%s: %v", context, err) // Note: log.Fatalf calls os.Exit(1) diff --git a/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go index 558a36e..e136487 100644 --- a/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go @@ -1,7 +1,7 @@ package fileutils import ( - "fmt" + "atlas-sdk-go/internal/errors" "io" "log" "os" @@ -11,19 +11,23 @@ import ( // WriteToFile copies everything from r into a new file at path. // It will create or truncate that file. func WriteToFile(r io.Reader, path string) error { + if r == nil { + return &errors.ValidationError{Message: "reader cannot be nil"} + } + f, err := os.Create(path) if err != nil { - return fmt.Errorf("create %s: %w", path, err) + return errors.WithContext(err, "create file") } defer SafeClose(f) if err := SafeCopy(f, r); err != nil { - return fmt.Errorf("write %s: %w", path, err) + return errors.WithContext(err, "write to file") } return nil } -// SafeClose closes c and logs a warning on error. +// SafeClose closes c and logs a warning on error func SafeClose(c io.Closer) { if c != nil { if err := c.Close(); err != nil { @@ -32,23 +36,26 @@ func SafeClose(c io.Closer) { } } -// SafeCopy copies src → dst and propagates any error (after logging). +// SafeCopy copies src → dst and propagates any error func SafeCopy(dst io.Writer, src io.Reader) error { + if dst == nil || src == nil { + return &errors.ValidationError{Message: "source and destination cannot be nil"} + } + if _, err := io.Copy(dst, src); err != nil { - log.Printf("warning: copy failed: %v", err) - return err + return errors.WithContext(err, "copy data") } return nil } // :remove-start: -// SafeDelete removes files generated in the specified directory. +// SafeDelete removes files generated in the specified directory // NOTE: INTERNAL ONLY FUNCTION func SafeDelete(dir string) error { err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { - return err + return errors.WithContext(err, "walking directory") } if !info.IsDir() { if removeErr := os.Remove(path); removeErr != nil { @@ -57,8 +64,9 @@ func SafeDelete(dir string) error { } return nil }) + if err != nil { - return err + return errors.WithContext(err, "cleaning up directory") } return nil } diff --git a/usage-examples/go/atlas-sdk-go/internal/fileutils/path.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/path.go new file mode 100644 index 0000000..3e3e6a8 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/path.go @@ -0,0 +1,40 @@ +package fileutils + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +// GenerateOutputPath constructs a valid file path based on the given directory, prefix, and optional extension. +// It returns the full path to the generated file with formatted filename or an error if any operation fails. +// +// NOTE: You can define a default global directory for all generated files by setting the ATLAS_DOWNLOADS_DIR environment variable. +func GenerateOutputPath(dir, prefix, extension string) (string, error) { + // If default download directory is set in .env, prepend it to the provided dir + defaultDir := os.Getenv("ATLAS_DOWNLOADS_DIR") + if defaultDir != "" { + dir = filepath.Join(defaultDir, dir) + } + + // Create directory if it doesn't exist + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create output directory: %w", err) + } + + timestamp := time.Now().Format("20060102") + var filename string + if extension == "" { + filename = fmt.Sprintf("%s_%s", prefix, timestamp) + } else { + filename = fmt.Sprintf("%s_%s.%s", prefix, timestamp, extension) + } + + filename = filepath.Clean(filename) + if len(filename) > 255 { + return "", fmt.Errorf("filename exceeds maximum length of 255 characters: %s", filename) + } + + return filepath.Join(dir, filename), nil +} diff --git a/usage-examples/go/atlas-sdk-go/internal/fileutils/path_test.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/path_test.go new file mode 100644 index 0000000..43c235b --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/path_test.go @@ -0,0 +1,90 @@ +package fileutils + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateOutputPath_WithExtension(t *testing.T) { + t.Parallel() + dir := t.TempDir() + prefix := "test-file" + extension := "json" + + path, err := GenerateOutputPath(dir, prefix, extension) + + require.NoError(t, err) + assert.Contains(t, path, dir) + assert.Contains(t, path, prefix) + assert.True(t, strings.HasSuffix(path, ".json")) + + currentDate := time.Now().Format("20060102") + assert.Contains(t, path, currentDate) +} + +func TestGenerateOutputPath_WithoutExtension(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + prefix := "test-file" + path, err := GenerateOutputPath(dir, prefix, "") + + require.NoError(t, err) + assert.Contains(t, path, dir) + assert.Contains(t, path, prefix) + + currentDate := time.Now().Format("20060102") + assert.Contains(t, path, currentDate) + assert.NotContains(t, path, ".") +} + +func TestGenerateOutputPath_CreatesDirectory(t *testing.T) { + t.Parallel() + + // Setup: create a temp base directory + baseDir := t.TempDir() + // Define subdirectory that doesn't exist yet + newDir := filepath.Join(baseDir, "new-dir") + prefix := "test-file" + + _, err := GenerateOutputPath(newDir, prefix, "txt") + + require.NoError(t, err) + + // Check that directory was created + dirInfo, err := os.Stat(newDir) + require.NoError(t, err) + assert.True(t, dirInfo.IsDir()) +} + +func TestGenerateOutputPath_WithEnvironmentVariable(t *testing.T) { + baseDir := t.TempDir() + t.Setenv("ATLAS_DOWNLOADS_DIR", baseDir) // NOTE: cannot use t.Setenv with t.Parallel() + subDir := "reports" + prefix := "test-file" + + path, err := GenerateOutputPath(subDir, prefix, "csv") + + require.NoError(t, err) + expectedBase := filepath.Join(baseDir, subDir) + assert.True(t, strings.HasPrefix(path, expectedBase)) +} + +func TestGenerateOutputPath_ExcessiveFilenameLength(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + prefix := strings.Repeat("very-long-prefix", 20) // Creates a ~300 char prefix + + path, err := GenerateOutputPath(dir, prefix, "txt") + + require.Error(t, err) + assert.Contains(t, err.Error(), "filename exceeds maximum length") + assert.Empty(t, path) +} diff --git a/usage-examples/go/atlas-sdk-go/internal/fileutils/paths.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/paths.go deleted file mode 100644 index f710f05..0000000 --- a/usage-examples/go/atlas-sdk-go/internal/fileutils/paths.go +++ /dev/null @@ -1,27 +0,0 @@ -package fileutils - -import ( - "fmt" - "path/filepath" - "time" -) - -// GenerateOutputPath creates a file path based on the base directory, prefix, and optional extension. -func GenerateOutputPath(baseDir, prefix, extension string) (string, error) { - if baseDir == "" { - return "", fmt.Errorf("baseDir cannot be empty") - } - if prefix == "" { - return "", fmt.Errorf("prefix cannot be empty") - } - - timestamp := time.Now().Format("20060102") - var filename string - if extension == "" { - filename = fmt.Sprintf("%s_%s", prefix, timestamp) - } else { - filename = fmt.Sprintf("%s_%s.%s", prefix, timestamp, extension) - } - - return filepath.Join(baseDir, filename), nil -} diff --git a/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go b/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go index 96a57fd..9c56af8 100644 --- a/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go +++ b/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go @@ -1,24 +1,24 @@ package logs import ( - "atlas-sdk-go/examples/internal" + "atlas-sdk-go/internal/errors" "context" - "fmt" - "io" - "go.mongodb.org/atlas-sdk/v20250219001/admin" + "io" ) -// FetchHostLogs calls the Atlas SDK and returns the raw, compressed log stream. +// FetchHostLogs retrieves logs for a specific host in a given Atlas project. +// Accepts a context for the request, an MonitoringAndLogsApi client instance, and +// the request parameters. +// Returns the raw, compressed log stream or an error if the operation fails. func FetchHostLogs(ctx context.Context, sdk admin.MonitoringAndLogsApi, p *admin.GetHostLogsApiParams) (io.ReadCloser, error) { req := sdk.GetHostLogs(ctx, p.GroupId, p.HostName, p.LogName) - rc, _, err := req.Execute() if err != nil { - return nil, internal.FormatError("fetch logs", p.HostName, err) + return nil, errors.FormatError("fetch logs", p.HostName, err) } if rc == nil { - return nil, fmt.Errorf("no data returned for host %q, log %q", p.HostName, p.LogName) + return nil, &errors.NotFoundError{Resource: "logs", ID: p.HostName + "/" + p.LogName} } return rc, nil } diff --git a/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go b/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go index 50dba52..ffde967 100644 --- a/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go +++ b/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go @@ -1,13 +1,17 @@ package logs import ( - "atlas-sdk-go/internal/fileutils" + internalerrors "atlas-sdk-go/internal/errors" "context" + "errors" "fmt" "io" "strings" "testing" + "atlas-sdk-go/internal/fileutils" + + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "go.mongodb.org/atlas-sdk/v20250219001/admin" @@ -26,10 +30,11 @@ func TestFetchHostLogs_Unit(t *testing.T) { } cases := []struct { - name string - setup func(m *mockadmin.MonitoringAndLogsApi) - wantErr bool - wantBody string + name string + setup func(m *mockadmin.MonitoringAndLogsApi) + wantErr bool + wantBody string + errorType string }{ { name: "API error", @@ -56,6 +61,19 @@ func TestFetchHostLogs_Unit(t *testing.T) { Return(io.NopCloser(strings.NewReader("log-data")), nil, nil).Once() }, }, + { + name: "NotFoundError when response is nil", + wantErr: true, + errorType: "NotFoundError", + setup: func(m *mockadmin.MonitoringAndLogsApi) { + m.EXPECT(). + GetHostLogs(mock.Anything, params.GroupId, params.HostName, params.LogName). + Return(admin.GetHostLogsApiRequest{ApiService: m}).Once() + m.EXPECT(). + GetHostLogsExecute(mock.Anything). + Return(nil, nil, nil).Once() // Return nil response but no error + }, + }, } for _, tc := range cases { @@ -66,8 +84,19 @@ func TestFetchHostLogs_Unit(t *testing.T) { tc.setup(mockSvc) rc, err := FetchHostLogs(ctx, mockSvc, params) + if tc.wantErr { - require.ErrorContainsf(t, err, "failed to fetch logs", "expected API error") + require.Error(t, err) + + if tc.errorType == "NotFoundError" { + var notFoundErr *internalerrors.NotFoundError + require.True(t, errors.As(err, ¬FoundErr), "expected error to be *errors.NotFoundError") + assert.Equal(t, "logs", notFoundErr.Resource) + assert.Equal(t, params.HostName+"/"+params.LogName, notFoundErr.ID) + } else { + require.ErrorContains(t, err, "fetch logs", "expected API error") + } + require.Nil(t, rc) return } diff --git a/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go b/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go index 5a2f032..63d4b25 100644 --- a/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go +++ b/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go @@ -1,24 +1,25 @@ package metrics import ( - "atlas-sdk-go/examples/internal" + "atlas-sdk-go/internal/errors" "context" - "fmt" "go.mongodb.org/atlas-sdk/v20250219001/admin" ) -// FetchDiskMetrics returns measurements for a specified disk partition +// FetchDiskMetrics returns measurements for a specified disk partition in a MongoDB Atlas project. +// Requires the group ID, process ID, partition name, and parameters for measurement type, granularity, and period. func FetchDiskMetrics(ctx context.Context, sdk admin.MonitoringAndLogsApi, p *admin.GetDiskMeasurementsApiParams) (*admin.ApiMeasurementsGeneralViewAtlas, error) { req := sdk.GetDiskMeasurements(ctx, p.GroupId, p.PartitionName, p.ProcessId) req = req.Granularity(*p.Granularity).Period(*p.Period).M(*p.M) r, _, err := req.Execute() if err != nil { - return nil, internal.FormatError("fetch disk metrics", p.PartitionName, err) + return nil, errors.FormatError("fetch disk metrics", p.ProcessId+"/"+p.PartitionName, err) } + if r == nil || !r.HasMeasurements() || len(r.GetMeasurements()) == 0 { - return nil, fmt.Errorf("no metrics for partition %q on process %q", p.PartitionName, p.ProcessId) + return nil, &errors.NotFoundError{Resource: "disk metrics", ID: p.ProcessId + "/" + p.PartitionName} } return r, nil } diff --git a/usage-examples/go/atlas-sdk-go/internal/metrics/process.go b/usage-examples/go/atlas-sdk-go/internal/metrics/process.go index c0ff685..35a438b 100644 --- a/usage-examples/go/atlas-sdk-go/internal/metrics/process.go +++ b/usage-examples/go/atlas-sdk-go/internal/metrics/process.go @@ -1,24 +1,23 @@ package metrics import ( - "atlas-sdk-go/examples/internal" + "atlas-sdk-go/internal/errors" "context" - "fmt" - "go.mongodb.org/atlas-sdk/v20250219001/admin" ) -// FetchProcessMetrics returns measurements for a specified host process +// FetchProcessMetrics returns measurements for a specified host process in a MongoDB Atlas project. +// Requires the group ID, process ID, and parameters for measurement type, granularity, and period. func FetchProcessMetrics(ctx context.Context, sdk admin.MonitoringAndLogsApi, p *admin.GetHostMeasurementsApiParams) (*admin.ApiMeasurementsGeneralViewAtlas, error) { req := sdk.GetHostMeasurements(ctx, p.GroupId, p.ProcessId) req = req.Granularity(*p.Granularity).Period(*p.Period).M(*p.M) r, _, err := req.Execute() if err != nil { - return nil, internal.FormatError("fetch process metrics", p.GroupId, err) + return nil, errors.FormatError("fetch process metrics", p.ProcessId, err) } if r == nil || !r.HasMeasurements() || len(r.GetMeasurements()) == 0 { - return nil, fmt.Errorf("no metrics for process %q", p.ProcessId) + return nil, &errors.NotFoundError{Resource: "process metrics", ID: p.ProcessId} } return r, nil } From 5f7376fbe027fc2a46e8fdac88c297a5a15ad4b6 Mon Sep 17 00:00:00 2001 From: cbullinger Date: Wed, 2 Jul 2025 13:26:19 -0400 Subject: [PATCH 04/11] Generate Bluehawk files --- .../go/atlas-sdk-go/main.snippet.get-logs.go | 58 ++++---- .../main.snippet.get-metrics-dev.go | 31 ++-- .../main.snippet.get-metrics-prod.go | 29 ++-- .../main.snippet.historical-billing.go | 99 +++++++++++++ .../atlas-sdk-go/main.snippet.line-items.go | 98 +++++++++++++ .../main.snippet.linked-billing.go | 66 +++++++++ .../go/atlas-sdk-go/project-copy/README.md | 34 +++-- .../project-copy/configs/ignore.config.json | 7 + .../examples/billing/historical/main.go | 98 +++++++++++++ .../examples/billing/line_items/main.go | 97 +++++++++++++ .../examples/billing/linked_orgs/main.go | 65 +++++++++ .../examples/monitoring/logs/main.go | 71 ++++++++++ .../examples/monitoring/metrics_disk/main.go | 78 ++++++++++ .../monitoring/metrics_process/main.go | 63 ++++++++ .../project-copy/internal/auth/client.go | 16 ++- .../internal/billing/collector.go | 134 ++++++++++++++++++ .../project-copy/internal/billing/invoices.go | 132 +++++++++++++++++ .../internal/billing/linked_billing.go | 48 +++++++ .../internal/billing/sku_classifier.go | 53 +++++++ .../project-copy/internal/config/loadall.go | 6 +- .../internal/config/loadconfig.go | 42 +++--- .../project-copy/internal/config/loadenv.go | 6 +- .../internal/data/export/formats.go | 118 +++++++++++++++ .../project-copy/internal/errors/utils.go | 48 +++++++ .../internal/fileutils/compress.go | 33 +++++ .../project-copy/internal/fileutils/io.go | 50 +++++++ .../project-copy/internal/fileutils/path.go | 40 ++++++ .../project-copy/internal/logs/fetch.go | 16 ++- .../project-copy/internal/metrics/disk.go | 13 +- .../project-copy/internal/metrics/process.go | 12 +- 30 files changed, 1554 insertions(+), 107 deletions(-) create mode 100644 generated-usage-examples/go/atlas-sdk-go/main.snippet.historical-billing.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/main.snippet.linked-billing.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/configs/ignore.config.json create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/historical/main.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/linked_orgs/main.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/logs/main.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_disk/main.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/collector.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/invoices.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/linked_billing.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/sku_classifier.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/data/export/formats.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/errors/utils.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/compress.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go create mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/path.go diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-logs.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-logs.go index 6c9acd3..6b1d0fe 100644 --- a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-logs.go +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-logs.go @@ -5,57 +5,67 @@ import ( "context" "fmt" "log" - "os" - "path/filepath" - "time" - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - - "atlas-sdk-go/internal" "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/fileutils" "atlas-sdk-go/internal/logs" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) func main() { - _ = godotenv.Load() + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + secrets, cfg, err := config.LoadAll("configs/config.json") if err != nil { - log.Fatalf("config load: %v", err) + errors.ExitWithError("Failed to load configuration", err) } - sdk, err := auth.NewClient(cfg, secrets) + client, err := auth.NewClient(cfg, secrets) if err != nil { - log.Fatalf("client init: %v", err) + errors.ExitWithError("Failed to initialize authentication client", err) } ctx := context.Background() + + // Fetch logs with the provided parameters p := &admin.GetHostLogsApiParams{ GroupId: cfg.ProjectID, HostName: cfg.HostName, LogName: "mongodb", } - ts := time.Now().Format("20060102_150405") - base := fmt.Sprintf("%s_%s_%s", p.HostName, p.LogName, ts) - outDir := "logs" - os.MkdirAll(outDir, 0o755) - gzPath := filepath.Join(outDir, base+".gz") - txtPath := filepath.Join(outDir, base+".txt") + rc, err := logs.FetchHostLogs(ctx, client.MonitoringAndLogsApi, p) + if err != nil { + errors.ExitWithError("Failed to fetch logs", err) + } + defer fileutils.SafeClose(rc) - rc, err := logs.FetchHostLogs(ctx, sdk.MonitoringAndLogsApi, p) + // Prepare output paths + outDir := "logs" + prefix := fmt.Sprintf("%s_%s", p.HostName, p.LogName) + gzPath, err := fileutils.GenerateOutputPath(outDir, prefix, "gz") + if err != nil { + errors.ExitWithError("Failed to generate GZ output path", err) + } + txtPath, err := fileutils.GenerateOutputPath(outDir, prefix, "txt") if err != nil { - log.Fatalf("download logs: %v", err) + errors.ExitWithError("Failed to generate TXT output path", err) } - defer internal.SafeClose(rc) - if err := logs.WriteToFile(rc, gzPath); err != nil { - log.Fatalf("save gz: %v", err) + // Save compressed logs + if err := fileutils.WriteToFile(rc, gzPath); err != nil { + errors.ExitWithError("Failed to save compressed logs", err) } fmt.Println("Saved compressed log to", gzPath) - if err := logs.DecompressGzip(gzPath, txtPath); err != nil { - log.Fatalf("decompress: %v", err) + // Decompress logs + if err := fileutils.DecompressGzip(gzPath, txtPath); err != nil { + errors.ExitWithError("Failed to decompress logs", err) } fmt.Println("Uncompressed log to", txtPath) } diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-dev.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-dev.go index 8f96696..b76bf54 100644 --- a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-dev.go +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-dev.go @@ -7,27 +7,33 @@ import ( "fmt" "log" - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" "atlas-sdk-go/internal/metrics" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) func main() { - _ = godotenv.Load() + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + secrets, cfg, err := config.LoadAll("configs/config.json") if err != nil { - log.Fatalf("config load: %v", err) + errors.ExitWithError("Failed to load configuration", err) } - sdk, err := auth.NewClient(cfg, secrets) + client, err := auth.NewClient(cfg, secrets) if err != nil { - log.Fatalf("client init: %v", err) + errors.ExitWithError("Failed to initialize authentication client", err) } ctx := context.Background() + + // Fetch disk metrics with the provided parameters p := &admin.GetDiskMeasurementsApiParams{ GroupId: cfg.ProjectID, ProcessId: cfg.ProcessID, @@ -36,13 +42,16 @@ func main() { Granularity: admin.PtrString("P1D"), Period: admin.PtrString("P1D"), } - - view, err := metrics.FetchDiskMetrics(ctx, sdk.MonitoringAndLogsApi, p) + view, err := metrics.FetchDiskMetrics(ctx, client.MonitoringAndLogsApi, p) if err != nil { - log.Fatalf("disk metrics: %v", err) + errors.ExitWithError("Failed to fetch disk metrics", err) } - out, _ := json.MarshalIndent(view, "", " ") + // Output metrics + out, err := json.MarshalIndent(view, "", " ") + if err != nil { + errors.ExitWithError("Failed to format metrics data", err) + } fmt.Println(string(out)) } diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go index 62c8dd4..74eef51 100644 --- a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go @@ -2,32 +2,39 @@ package main import ( + "atlas-sdk-go/internal/errors" "context" "encoding/json" "fmt" "log" + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" + "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/metrics" ) func main() { - _ = godotenv.Load() + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + secrets, cfg, err := config.LoadAll("configs/config.json") if err != nil { - log.Fatalf("config load: %v", err) + errors.ExitWithError("Failed to load configuration", err) } - sdk, err := auth.NewClient(cfg, secrets) + client, err := auth.NewClient(cfg, secrets) if err != nil { - log.Fatalf("client init: %v", err) + errors.ExitWithError("Failed to initialize authentication client", err) } ctx := context.Background() + + // Fetch process metrics with the provided parameters p := &admin.GetHostMeasurementsApiParams{ GroupId: cfg.ProjectID, ProcessId: cfg.ProcessID, @@ -42,12 +49,16 @@ func main() { Period: admin.PtrString("P7D"), } - view, err := metrics.FetchProcessMetrics(ctx, sdk.MonitoringAndLogsApi, p) + view, err := metrics.FetchProcessMetrics(ctx, client.MonitoringAndLogsApi, p) if err != nil { - log.Fatalf("process metrics: %v", err) + errors.ExitWithError("Failed to fetch process metrics", err) } - out, _ := json.MarshalIndent(view, "", " ") + // Output metrics + out, err := json.MarshalIndent(view, "", " ") + if err != nil { + errors.ExitWithError("Failed to format metrics data", err) + } fmt.Println(string(out)) } diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.historical-billing.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.historical-billing.go new file mode 100644 index 0000000..10d2bad --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.historical-billing.go @@ -0,0 +1,99 @@ +// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk +package main + +import ( + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/billing" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/data/export" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/fileutils" + "context" + "fmt" + "log" + "time" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + p := &admin.ListInvoicesApiParams{ + OrgId: cfg.OrgID, + } + + fmt.Printf("Fetching historical invoices for organization: %s\n", p.OrgId) + + // Fetch invoices from the previous six months with the provided options + invoices, err := billing.ListInvoicesForOrg(ctx, client.InvoicesApi, p, + billing.WithViewLinkedInvoices(true), + billing.WithIncludeCount(true), + billing.WithDateRange(time.Now().AddDate(0, -6, 0), time.Now())) + if err != nil { + errors.ExitWithError("Failed to retrieve invoices", err) + } + + if invoices.GetTotalCount() > 0 { + fmt.Printf("Total count of invoices: %d\n", invoices.GetTotalCount()) + } else { + fmt.Println("No invoices found for the specified date range") + return + } + + // Export invoice data to be used in other systems or for reporting + outDir := "invoices" + prefix := fmt.Sprintf("historical_%s", p.OrgId) + + exportInvoicesToJSON(invoices, outDir, prefix) + exportInvoicesToCSV(invoices, outDir, prefix) +} + +func exportInvoicesToJSON(invoices *admin.PaginatedApiInvoiceMetadata, outDir, prefix string) { + jsonPath, err := fileutils.GenerateOutputPath(outDir, prefix, "json") + if err != nil { + errors.ExitWithError("Failed to generate JSON output path", err) + } + if err := export.ToJSON(invoices.GetResults(), jsonPath); err != nil { + errors.ExitWithError("Failed to write JSON file", err) + } + fmt.Printf("Exported invoice data to %s\n", jsonPath) +} + +func exportInvoicesToCSV(invoices *admin.PaginatedApiInvoiceMetadata, outDir, prefix string) { + csvPath, err := fileutils.GenerateOutputPath(outDir, prefix, "csv") + if err != nil { + errors.ExitWithError("Failed to generate CSV output path", err) + } + + // Set the headers and mapped rows for the CSV export + headers := []string{"InvoiceID", "Status", "Created", "AmountBilled"} + err = export.ToCSVWithMapper(invoices.GetResults(), csvPath, headers, func(invoice admin.BillingInvoiceMetadata) []string { + return []string{ + invoice.GetId(), + invoice.GetStatusName(), + invoice.GetCreated().Format(time.RFC3339), + fmt.Sprintf("%.2f", float64(invoice.GetAmountBilledCents())/100.0), + } + }) + if err != nil { + errors.ExitWithError("Failed to write CSV file", err) + } + + fmt.Printf("Exported invoice data to %s\n", csvPath) +} + diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go new file mode 100644 index 0000000..3005aea --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go @@ -0,0 +1,98 @@ +// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk +package main + +import ( + "context" + "fmt" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + "log" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/billing" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/data/export" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/fileutils" + + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + p := &admin.ListInvoicesApiParams{ + OrgId: cfg.OrgID, + } + + fmt.Printf("Fetching pending invoices for organization: %s\n", p.OrgId) + + details, err := billing.CollectLineItemBillingData(ctx, client.InvoicesApi, client.OrganizationsApi, p.OrgId, nil) + if err != nil { + errors.ExitWithError(fmt.Sprintf("Failed to retrieve pending invoices for %s", p.OrgId), err) + } + + fmt.Printf("Found %d line items in pending invoices\n", len(details)) + + // Export invoice data to be used in other systems or for reporting + outDir := "invoices" + prefix := fmt.Sprintf("pending_%s", p.OrgId) + + exportInvoicesToJSON(details, outDir, prefix) + exportInvoicesToCSV(details, outDir, prefix) +} + +func exportInvoicesToJSON(details []billing.Detail, outDir, prefix string) { + jsonPath, err := fileutils.GenerateOutputPath(outDir, prefix, "json") + if err != nil { + errors.ExitWithError("Failed to generate JSON output path", err) + } + + if err := export.ToJSON(details, jsonPath); err != nil { + errors.ExitWithError("Failed to write JSON file", err) + } + fmt.Printf("Exported billing data to %s\n", jsonPath) +} + +func exportInvoicesToCSV(details []billing.Detail, outDir, prefix string) { + csvPath, err := fileutils.GenerateOutputPath(outDir, prefix, "csv") + if err != nil { + errors.ExitWithError("Failed to generate CSV output path", err) + } + + // Set the headers and mapped rows for the CSV export + headers := []string{"Organization", "OrgID", "Project", "ProjectID", "Cluster", + "SKU", "Cost", "Date", "Provider", "Instance", "Category"} + err = export.ToCSVWithMapper(details, csvPath, headers, func(item billing.Detail) []string { + return []string{ + item.Org.Name, + item.Org.ID, + item.Project.Name, + item.Project.ID, + item.Cluster, + item.SKU, + fmt.Sprintf("%.2f", item.Cost), + item.Date.Format("2006-01-02"), + item.Provider, + item.Instance, + item.Category, + } + }) + if err != nil { + errors.ExitWithError("Failed to write CSV file", err) + } + fmt.Printf("Exported billing data to %s\n", csvPath) +} + diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.linked-billing.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.linked-billing.go new file mode 100644 index 0000000..38253c6 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.linked-billing.go @@ -0,0 +1,66 @@ +// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk +package main + +import ( + "context" + "fmt" + "log" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/billing" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + p := &admin.ListInvoicesApiParams{ + OrgId: cfg.OrgID, + } + + fmt.Printf("Fetching linked organizations for billing organization: %s\n", p.OrgId) + + invoices, err := billing.GetCrossOrgBilling(ctx, client.InvoicesApi, p) + if err != nil { + errors.ExitWithError(fmt.Sprintf("Failed to retrieve cross-organization billing data for %s", p.OrgId), err) + } + + displayLinkedOrganizations(invoices, p.OrgId) +} + +func displayLinkedOrganizations(invoices map[string][]admin.BillingInvoiceMetadata, primaryOrgID string) { + var linkedOrgs []string + for orgID := range invoices { + if orgID != primaryOrgID { + linkedOrgs = append(linkedOrgs, orgID) + } + } + + if len(linkedOrgs) == 0 { + fmt.Println("No linked organizations found for the billing organization") + return + } + + fmt.Printf("Found %d linked organizations:\n", len(linkedOrgs)) + for i, orgID := range linkedOrgs { + fmt.Printf(" %d. Organization ID: %s\n", i+1, orgID) + } +} + diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/README.md b/generated-usage-examples/go/atlas-sdk-go/project-copy/README.md index 65d80bc..b17f5cb 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/README.md +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/README.md @@ -14,7 +14,9 @@ Currently, the repository includes examples that demonstrate the following: - Authenticate with service accounts - Return cluster and database metrics - Download logs for a specific host +- Pull and parse line-item-level billing data - Return all linked organizations from a specific billing organization +- Get historical invoices for an organization - Programmatically manage Atlas resources As the Architecture Center documentation evolves, this repository will be updated with new examples @@ -24,20 +26,20 @@ and improvements to existing code. ```text . -├── cmd # Runnable examples by category -│ ├── get_linked_orgs/main.go -│ ├── get_logs/main.go -│ ├── get_metrics_disk/main.go -│ └── get_metrics_process/main.go +├── examples # Runnable examples by category +│ ├── billing/ +│ └── monitoring/ ├── configs # Atlas configuration template │ └── config.json ├── internal # Shared utilities and helpers │ ├── auth/ │ ├── billing/ │ ├── config/ +│ ├── data/ +│ ├── errors/ +│ ├── fileutils/ │ ├── logs/ -│ ├── metrics/ -│ └── utils.go +│ └── metrics/ ├── go.mod ├── go.sum ├── CHANGELOG.md # List of major changes to the project @@ -85,9 +87,17 @@ You can also adjust them to suit your needs: - Change output formats ### Billing -#### Get All Linked Organizations +#### Get Historical Invoices +```bash +go run examples/billing/historical/main.go +``` +#### Get Line-Item-Level Billing Data +```bash +go run examples/billing/line_items/main.go +``` +#### Get All Linked Organizations ```bash -go run cmd/get_linked_orgs/main.go +go run examples/billing/linked_orgs/main.go ``` ### Logs @@ -95,7 +105,7 @@ Logs output to `./logs` as `.gz` and `.txt`. #### Fetch All Host Logs ```bash -go run cmd/get_logs/main.go +go run examples/monitoring/logs/main.go ``` ### Metrics @@ -103,12 +113,12 @@ Metrics print to the console. #### Get Disk Measurements ```bash -go run cmd/get_metrics_disk/main.go +go run examples/monitoring/metrics_disk/main.go ``` #### Get Cluster Metrics ```bash -go run cmd/get_metrics_process/main.go +go run examples/monitoring/metrics_process/main.go ``` ## Changelog diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/configs/ignore.config.json b/generated-usage-examples/go/atlas-sdk-go/project-copy/configs/ignore.config.json new file mode 100644 index 0000000..3818a1a --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/configs/ignore.config.json @@ -0,0 +1,7 @@ +{ + "MONGODB_ATLAS_BASE_URL": "https://cloud.mongodb.com", + "ATLAS_ORG_ID": "5bfda007553855125605a5cf", + "ATLAS_PROJECT_ID": "5f60207f14dfb25d23101102", + "ATLAS_CLUSTER_NAME": "Cluster0", + "ATLAS_PROCESS_ID": "cluster0-shard-00-00.nr3ko.mongodb.net:27017" +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/historical/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/historical/main.go new file mode 100644 index 0000000..2c8b7bf --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/historical/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/billing" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/data/export" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/fileutils" + "context" + "fmt" + "log" + "time" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + p := &admin.ListInvoicesApiParams{ + OrgId: cfg.OrgID, + } + + fmt.Printf("Fetching historical invoices for organization: %s\n", p.OrgId) + + // Fetch invoices from the previous six months with the provided options + invoices, err := billing.ListInvoicesForOrg(ctx, client.InvoicesApi, p, + billing.WithViewLinkedInvoices(true), + billing.WithIncludeCount(true), + billing.WithDateRange(time.Now().AddDate(0, -6, 0), time.Now())) + if err != nil { + errors.ExitWithError("Failed to retrieve invoices", err) + } + + if invoices.GetTotalCount() > 0 { + fmt.Printf("Total count of invoices: %d\n", invoices.GetTotalCount()) + } else { + fmt.Println("No invoices found for the specified date range") + return + } + + // Export invoice data to be used in other systems or for reporting + outDir := "invoices" + prefix := fmt.Sprintf("historical_%s", p.OrgId) + + exportInvoicesToJSON(invoices, outDir, prefix) + exportInvoicesToCSV(invoices, outDir, prefix) +} + +func exportInvoicesToJSON(invoices *admin.PaginatedApiInvoiceMetadata, outDir, prefix string) { + jsonPath, err := fileutils.GenerateOutputPath(outDir, prefix, "json") + if err != nil { + errors.ExitWithError("Failed to generate JSON output path", err) + } + if err := export.ToJSON(invoices.GetResults(), jsonPath); err != nil { + errors.ExitWithError("Failed to write JSON file", err) + } + fmt.Printf("Exported invoice data to %s\n", jsonPath) +} + +func exportInvoicesToCSV(invoices *admin.PaginatedApiInvoiceMetadata, outDir, prefix string) { + csvPath, err := fileutils.GenerateOutputPath(outDir, prefix, "csv") + if err != nil { + errors.ExitWithError("Failed to generate CSV output path", err) + } + + // Set the headers and mapped rows for the CSV export + headers := []string{"InvoiceID", "Status", "Created", "AmountBilled"} + err = export.ToCSVWithMapper(invoices.GetResults(), csvPath, headers, func(invoice admin.BillingInvoiceMetadata) []string { + return []string{ + invoice.GetId(), + invoice.GetStatusName(), + invoice.GetCreated().Format(time.RFC3339), + fmt.Sprintf("%.2f", float64(invoice.GetAmountBilledCents())/100.0), + } + }) + if err != nil { + errors.ExitWithError("Failed to write CSV file", err) + } + + fmt.Printf("Exported invoice data to %s\n", csvPath) +} + diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go new file mode 100644 index 0000000..9928696 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "fmt" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + "log" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/billing" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/data/export" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/fileutils" + + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + p := &admin.ListInvoicesApiParams{ + OrgId: cfg.OrgID, + } + + fmt.Printf("Fetching pending invoices for organization: %s\n", p.OrgId) + + details, err := billing.CollectLineItemBillingData(ctx, client.InvoicesApi, client.OrganizationsApi, p.OrgId, nil) + if err != nil { + errors.ExitWithError(fmt.Sprintf("Failed to retrieve pending invoices for %s", p.OrgId), err) + } + + fmt.Printf("Found %d line items in pending invoices\n", len(details)) + + // Export invoice data to be used in other systems or for reporting + outDir := "invoices" + prefix := fmt.Sprintf("pending_%s", p.OrgId) + + exportInvoicesToJSON(details, outDir, prefix) + exportInvoicesToCSV(details, outDir, prefix) +} + +func exportInvoicesToJSON(details []billing.Detail, outDir, prefix string) { + jsonPath, err := fileutils.GenerateOutputPath(outDir, prefix, "json") + if err != nil { + errors.ExitWithError("Failed to generate JSON output path", err) + } + + if err := export.ToJSON(details, jsonPath); err != nil { + errors.ExitWithError("Failed to write JSON file", err) + } + fmt.Printf("Exported billing data to %s\n", jsonPath) +} + +func exportInvoicesToCSV(details []billing.Detail, outDir, prefix string) { + csvPath, err := fileutils.GenerateOutputPath(outDir, prefix, "csv") + if err != nil { + errors.ExitWithError("Failed to generate CSV output path", err) + } + + // Set the headers and mapped rows for the CSV export + headers := []string{"Organization", "OrgID", "Project", "ProjectID", "Cluster", + "SKU", "Cost", "Date", "Provider", "Instance", "Category"} + err = export.ToCSVWithMapper(details, csvPath, headers, func(item billing.Detail) []string { + return []string{ + item.Org.Name, + item.Org.ID, + item.Project.Name, + item.Project.ID, + item.Cluster, + item.SKU, + fmt.Sprintf("%.2f", item.Cost), + item.Date.Format("2006-01-02"), + item.Provider, + item.Instance, + item.Category, + } + }) + if err != nil { + errors.ExitWithError("Failed to write CSV file", err) + } + fmt.Printf("Exported billing data to %s\n", csvPath) +} + diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/linked_orgs/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/linked_orgs/main.go new file mode 100644 index 0000000..4fd48c8 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/linked_orgs/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "fmt" + "log" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/billing" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + p := &admin.ListInvoicesApiParams{ + OrgId: cfg.OrgID, + } + + fmt.Printf("Fetching linked organizations for billing organization: %s\n", p.OrgId) + + invoices, err := billing.GetCrossOrgBilling(ctx, client.InvoicesApi, p) + if err != nil { + errors.ExitWithError(fmt.Sprintf("Failed to retrieve cross-organization billing data for %s", p.OrgId), err) + } + + displayLinkedOrganizations(invoices, p.OrgId) +} + +func displayLinkedOrganizations(invoices map[string][]admin.BillingInvoiceMetadata, primaryOrgID string) { + var linkedOrgs []string + for orgID := range invoices { + if orgID != primaryOrgID { + linkedOrgs = append(linkedOrgs, orgID) + } + } + + if len(linkedOrgs) == 0 { + fmt.Println("No linked organizations found for the billing organization") + return + } + + fmt.Printf("Found %d linked organizations:\n", len(linkedOrgs)) + for i, orgID := range linkedOrgs { + fmt.Printf(" %d. Organization ID: %s\n", i+1, orgID) + } +} + diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/logs/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/logs/main.go new file mode 100644 index 0000000..fb29556 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/logs/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "fmt" + "log" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/fileutils" + "atlas-sdk-go/internal/logs" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + + // Fetch logs with the provided parameters + p := &admin.GetHostLogsApiParams{ + GroupId: cfg.ProjectID, + HostName: cfg.HostName, + LogName: "mongodb", + } + rc, err := logs.FetchHostLogs(ctx, client.MonitoringAndLogsApi, p) + if err != nil { + errors.ExitWithError("Failed to fetch logs", err) + } + defer fileutils.SafeClose(rc) + + // Prepare output paths + outDir := "logs" + prefix := fmt.Sprintf("%s_%s", p.HostName, p.LogName) + gzPath, err := fileutils.GenerateOutputPath(outDir, prefix, "gz") + if err != nil { + errors.ExitWithError("Failed to generate GZ output path", err) + } + txtPath, err := fileutils.GenerateOutputPath(outDir, prefix, "txt") + if err != nil { + errors.ExitWithError("Failed to generate TXT output path", err) + } + + // Save compressed logs + if err := fileutils.WriteToFile(rc, gzPath); err != nil { + errors.ExitWithError("Failed to save compressed logs", err) + } + fmt.Println("Saved compressed log to", gzPath) + + // Decompress logs + if err := fileutils.DecompressGzip(gzPath, txtPath); err != nil { + errors.ExitWithError("Failed to decompress logs", err) + } + fmt.Println("Uncompressed log to", txtPath) +} + diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_disk/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_disk/main.go new file mode 100644 index 0000000..615abe4 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_disk/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/metrics" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + + // Fetch disk metrics with the provided parameters + p := &admin.GetDiskMeasurementsApiParams{ + GroupId: cfg.ProjectID, + ProcessId: cfg.ProcessID, + PartitionName: "data", + M: &[]string{"DISK_PARTITION_SPACE_FREE", "DISK_PARTITION_SPACE_USED"}, + Granularity: admin.PtrString("P1D"), + Period: admin.PtrString("P1D"), + } + view, err := metrics.FetchDiskMetrics(ctx, client.MonitoringAndLogsApi, p) + if err != nil { + errors.ExitWithError("Failed to fetch disk metrics", err) + } + + // Output metrics + out, err := json.MarshalIndent(view, "", " ") + if err != nil { + errors.ExitWithError("Failed to format metrics data", err) + } + fmt.Println(string(out)) +} + +// NOTE: INTERNAL +// ** OUTPUT EXAMPLE ** +// { +// "measurements": [ +// { +// "name": "DISK_PARTITION_SPACE_FREE", +// "granularity": "P1D", +// "period": "P1D", +// "values": [ +// { +// "timestamp": "2023-10-01T00:00:00Z", +// "value": 1234567890 +// }, +// { +// "timestamp": "2023-10-02T00:00:00Z", +// "value": 1234567890 +// } +// ] +// }, +// ... +// ] +// } diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go new file mode 100644 index 0000000..b96cad9 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "atlas-sdk-go/internal/errors" + "context" + "encoding/json" + "fmt" + "log" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" + + "github.com/joho/godotenv" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/metrics" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not loaded: %v", err) + } + + secrets, cfg, err := config.LoadAll("configs/config.json") + if err != nil { + errors.ExitWithError("Failed to load configuration", err) + } + + client, err := auth.NewClient(cfg, secrets) + if err != nil { + errors.ExitWithError("Failed to initialize authentication client", err) + } + + ctx := context.Background() + + // Fetch process metrics with the provided parameters + p := &admin.GetHostMeasurementsApiParams{ + GroupId: cfg.ProjectID, + ProcessId: cfg.ProcessID, + M: &[]string{ + "OPCOUNTER_INSERT", "OPCOUNTER_QUERY", "OPCOUNTER_UPDATE", "TICKETS_AVAILABLE_READS", + "TICKETS_AVAILABLE_WRITE", "CONNECTIONS", "QUERY_TARGETING_SCANNED_OBJECTS_PER_RETURNED", + "QUERY_TARGETING_SCANNED_PER_RETURNED", "SYSTEM_CPU_GUEST", "SYSTEM_CPU_IOWAIT", + "SYSTEM_CPU_IRQ", "SYSTEM_CPU_KERNEL", "SYSTEM_CPU_NICE", "SYSTEM_CPU_SOFTIRQ", + "SYSTEM_CPU_STEAL", "SYSTEM_CPU_USER", + }, + Granularity: admin.PtrString("PT1H"), + Period: admin.PtrString("P7D"), + } + + view, err := metrics.FetchProcessMetrics(ctx, client.MonitoringAndLogsApi, p) + if err != nil { + errors.ExitWithError("Failed to fetch process metrics", err) + } + + // Output metrics + out, err := json.MarshalIndent(view, "", " ") + if err != nil { + errors.ExitWithError("Failed to format metrics data", err) + } + fmt.Println(string(out)) +} + diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/auth/client.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/auth/client.go index 7e8f192..a0b81ce 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/auth/client.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/auth/client.go @@ -2,16 +2,24 @@ package auth import ( "context" - "fmt" "atlas-sdk-go/internal/config" + "atlas-sdk-go/internal/errors" "go.mongodb.org/atlas-sdk/v20250219001/admin" ) -// NewClient initializes and returns an authenticated Atlas API client -// using OAuth2 with service account credentials (recommended) +// NewClient initializes and returns an authenticated Atlas API client using OAuth2 with service account credentials (recommended) +// See: https://www.mongodb.com/docs/atlas/architecture/current/auth/#service-accounts func NewClient(cfg *config.Config, secrets *config.Secrets) (*admin.APIClient, error) { + if cfg == nil { + return nil, &errors.ValidationError{Message: "config cannot be nil"} + } + + if secrets == nil { + return nil, &errors.ValidationError{Message: "secrets cannot be nil"} + } + sdk, err := admin.NewClient( admin.UseBaseURL(cfg.BaseURL), admin.UseOAuthAuth(context.Background(), @@ -20,7 +28,7 @@ func NewClient(cfg *config.Config, secrets *config.Secrets) (*admin.APIClient, e ), ) if err != nil { - return nil, fmt.Errorf("create atlas client: %w", err) + return nil, errors.WithContext(err, "create atlas client") } return sdk, nil } diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/collector.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/collector.go new file mode 100644 index 0000000..c17750d --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/collector.go @@ -0,0 +1,134 @@ +package billing + +import ( + "context" + "fmt" + "time" + + "atlas-sdk-go/internal/errors" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// Detail represents the transformed billing line item +type Detail struct { + Org OrgInfo `json:"org"` + Project ProjectInfo `json:"project"` + Cluster string `json:"cluster"` + SKU string `json:"sku"` + Cost float64 `json:"cost"` + Date time.Time `json:"date"` + Provider string `json:"provider"` + Instance string `json:"instance"` + Category string `json:"category"` +} + +// OrgInfo contains organization identifier information +type OrgInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// ProjectInfo contains project identifier information +type ProjectInfo struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// CollectLineItemBillingData retrieves all pending invoices for the specified organization, +// transforms them into detailed billing records, and filters out items processed before lastProcessedDate. +// Returns a slice of billing Details or an error if no valid invoices or line items are found. +func CollectLineItemBillingData(ctx context.Context, sdk admin.InvoicesApi, orgSdk admin.OrganizationsApi, orgID string, lastProcessedDate *time.Time) ([]Detail, error) { + req := sdk.ListPendingInvoices(ctx, orgID) + r, _, err := req.Execute() + + if err != nil { + return nil, errors.FormatError("list pending invoices", orgID, err) + } + if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { + return nil, &errors.NotFoundError{Resource: "pending invoices", ID: orgID} + } + + fmt.Printf("Found %d pending invoice(s)\n", len(r.GetResults())) + + // Get organization name + orgName, err := getOrganizationName(ctx, orgSdk, orgID) + if err != nil { + // Non-critical error, continue with orgID as name + fmt.Printf("Warning: %v\n", err) + orgName = orgID + } + + // Process invoices and collect line items + billingDetails, err := processInvoices(r.GetResults(), orgID, orgName, lastProcessedDate) + if err != nil { + return nil, errors.WithContext(err, "processing invoices") + } + + if len(billingDetails) == 0 { + return nil, &errors.NotFoundError{Resource: "line items in pending invoices", ID: orgID} + } + + return billingDetails, nil +} + +// processInvoices extracts and transforms billing line items from invoices into Detail structs. +// The function iterates through all invoices and their line items, filters out items processed before +// lastProcessedDate (if provided), then determines line item details, such as organization and project, +// pricing, and SKU-based information. +func processInvoices(invoices []admin.BillingInvoice, orgID, orgName string, lastProcessedDate *time.Time) ([]Detail, error) { + var billingDetails []Detail + + for _, invoice := range invoices { + fmt.Printf("Processing invoice ID: %s\n", invoice.GetId()) + + for _, lineItem := range invoice.GetLineItems() { + startDate := lineItem.GetStartDate() + if lastProcessedDate != nil && !startDate.After(*lastProcessedDate) { + continue + } + + detail := Detail{ + Org: OrgInfo{ + ID: orgID, + Name: orgName, + }, + Project: ProjectInfo{ + ID: lineItem.GetGroupId(), + Name: lineItem.GetGroupName(), + }, + Cluster: getValueOrDefault(lineItem.GetClusterName(), "N/A"), + SKU: lineItem.GetSku(), + Cost: float64(lineItem.GetTotalPriceCents()) / 100.0, + Date: startDate, + Provider: determineProvider(lineItem.GetSku()), + Instance: determineInstance(lineItem.GetSku()), + Category: determineCategory(lineItem.GetSku()), + } + billingDetails = append(billingDetails, detail) + } + } + + return billingDetails, nil +} + +// getOrganizationName fetches organization name from API or returns orgID if not found +func getOrganizationName(ctx context.Context, sdk admin.OrganizationsApi, orgID string) (string, error) { + req := sdk.GetOrganization(ctx, orgID) + org, _, err := req.Execute() + if err != nil { + return orgID, errors.FormatError("get organization details", orgID, err) + } + if org == nil { + return orgID, fmt.Errorf("organization response is nil for ID %s", orgID) + } + return org.GetName(), nil +} + +// getValueOrDefault returns the value or a default if empty +func getValueOrDefault(value string, defaultValue string) string { + if value == "" { + return defaultValue + } + return value +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/invoices.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/invoices.go new file mode 100644 index 0000000..1b0fb2d --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/invoices.go @@ -0,0 +1,132 @@ +package billing + +import ( + "context" + "time" + + "atlas-sdk-go/internal/errors" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// InvoiceOption defines a function type that modifies the parameters for listing invoices +type InvoiceOption func(*admin.ListInvoicesApiParams) + +// WithIncludeCount sets the optional includeCount parameter (default: true) +func WithIncludeCount(includeCount bool) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.IncludeCount = &includeCount + } +} + +// WithItemsPerPage sets the optional itemsPerPage parameter (default: 100) +func WithItemsPerPage(itemsPerPage int) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.ItemsPerPage = &itemsPerPage + } +} + +// WithPageNum sets the optional pageNum parameter (default: 1) +func WithPageNum(pageNum int) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.PageNum = &pageNum + } +} + +// WithViewLinkedInvoices sets the optional viewLinkedInvoices parameter (default: true) +func WithViewLinkedInvoices(viewLinkedInvoices bool) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.ViewLinkedInvoices = &viewLinkedInvoices + } +} + +// WithStatusNames sets the optional statusNames parameter (default: include all statuses) +// Possible status names: "PENDING" "CLOSED" "FORGIVEN" "FAILED" "PAID" "FREE" "PREPAID" "INVOICED" +func WithStatusNames(statusNames []string) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.StatusNames = &statusNames + } +} + +// WithDateRange sets the optional fromDate and toDate parameters (default: earliest valid start to latest valid end) +func WithDateRange(fromDate, toDate time.Time) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + from := fromDate.Format(time.DateOnly) // Format to "YYYY-MM-DD" string + to := toDate.Format(time.DateOnly) // Format to "YYYY-MM-DD" string + p.FromDate = &from + p.ToDate = &to + } +} + +// WithSortBy sets the optional sortBy parameter (default: "END_DATE") +func WithSortBy(sortBy string) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.SortBy = &sortBy + } +} + +// WithOrderBy sets the optional orderBy parameter (default: "desc") +func WithOrderBy(orderBy string) InvoiceOption { + return func(p *admin.ListInvoicesApiParams) { + p.OrderBy = &orderBy + } +} + +// ListInvoicesForOrg returns all eligible invoices for the given Atlas organization, +// including linked organizations when cross-organization billing is enabled. +// Accepts a context for the request, an InvoicesApi client instance, the ID of the +// organization to retrieve invoices for, and optional query parameters. +// Returns the invoice results or an error if the operation fails. +// Use options to customize pagination, filtering, and sorting (see With* functions). +// +// Required Permissions: +// - Organization Billing Viewer role can view invoices for the organization +// - Organization Billing Admin or Organization Owner role can view invoices and linked invoices for the organization +func ListInvoicesForOrg(ctx context.Context, sdk admin.InvoicesApi, p *admin.ListInvoicesApiParams, opts ...InvoiceOption) (*admin.PaginatedApiInvoiceMetadata, error) { + params := &admin.ListInvoicesApiParams{ + OrgId: p.OrgId, + } + + for _, opt := range opts { + opt(params) + } + + req := sdk.ListInvoices(ctx, params.OrgId) + + if params.IncludeCount != nil { + req = req.IncludeCount(*params.IncludeCount) + } + if params.ItemsPerPage != nil { + req = req.ItemsPerPage(*params.ItemsPerPage) + } + if params.PageNum != nil { + req = req.PageNum(*params.PageNum) + } + if params.ViewLinkedInvoices != nil { + req = req.ViewLinkedInvoices(*params.ViewLinkedInvoices) + } + if params.StatusNames != nil { + req = req.StatusNames(*params.StatusNames) + } + if params.FromDate != nil { + req = req.FromDate(*params.FromDate) + } + if params.ToDate != nil { + req = req.ToDate(*params.ToDate) + } + if params.SortBy != nil { + req = req.SortBy(*params.SortBy) + } + if params.OrderBy != nil { + req = req.OrderBy(*params.OrderBy) + } + + r, _, err := req.Execute() + if err != nil { + return nil, errors.FormatError("list invoices", p.OrgId, err) + } + if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { + return nil, &errors.NotFoundError{Resource: "Invoices", ID: p.OrgId} + } + return r, nil +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/linked_billing.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/linked_billing.go new file mode 100644 index 0000000..2c12337 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/linked_billing.go @@ -0,0 +1,48 @@ +package billing + +import ( + "context" + + "atlas-sdk-go/internal/errors" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// GetCrossOrgBilling returns a map of all billing invoices for the given organization +// and any linked organizations, grouped by organization ID. +// It accepts a context for the request, an InvoicesApi client instance, the ID of the +// organization to retrieve invoices for, and optional parameters to customize the query. +// It returns a map of organization IDs as keys with corresponding slices of metadata +// as values or an error if the invoice retrieval fails. +// +// Required Permissions: +// - Organization Billing Viewer role can view invoices for the organization. +// - Organization Billing Admin or Organization Owner role can view invoices and linked invoices for the organization. +func GetCrossOrgBilling(ctx context.Context, sdk admin.InvoicesApi, p *admin.ListInvoicesApiParams, opts ...InvoiceOption) (map[string][]admin.BillingInvoiceMetadata, error) { + r, err := ListInvoicesForOrg(ctx, sdk, p, opts...) + if err != nil { + return nil, errors.FormatError("get cross-organization billing", p.OrgId, err) + } + + crossOrgBilling := make(map[string][]admin.BillingInvoiceMetadata) + if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { + return crossOrgBilling, nil + } + + crossOrgBilling[p.OrgId] = r.GetResults() + for _, invoice := range r.GetResults() { + if !invoice.HasLinkedInvoices() || len(invoice.GetLinkedInvoices()) == 0 { + continue + } + + for _, linkedInvoice := range invoice.GetLinkedInvoices() { + orgID := linkedInvoice.GetOrgId() + if orgID == "" { + continue + } + crossOrgBilling[orgID] = append(crossOrgBilling[orgID], linkedInvoice) + } + } + + return crossOrgBilling, nil +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/sku_classifier.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/sku_classifier.go new file mode 100644 index 0000000..c65dc1a --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/sku_classifier.go @@ -0,0 +1,53 @@ +package billing + +import ( + "strings" +) + +// determineProvider identifies the cloud provider based on SKU +func determineProvider(sku string) string { + if strings.Contains(sku, "AWS") { + return "AWS" + } else if strings.Contains(sku, "AZURE") { + return "AZURE" + } else if strings.Contains(sku, "GCP") { + return "GCP" + } + return "n/a" +} + +// determineInstance extracts the instance type from SKU +func determineInstance(sku string) string { + parts := strings.Split(sku, "_INSTANCE_") + if len(parts) > 1 { + return parts[1] + } + return "non-instance" +} + +// determineCategory categorizes the SKU +func determineCategory(sku string) string { + categoryPatterns := map[string]string{ + "_INSTANCE": "instances", + "BACKUP": "backup", + "PIT_RESTORE": "backup", + "DATA_TRANSFER": "data xfer", + "STORAGE": "storage", + "BI_CONNECTOR": "bi-connector", + "DATA_LAKE": "data lake", + "AUDITING": "audit", + "ATLAS_SUPPORT": "support", + "FREE_SUPPORT": "free support", + "CHARTS": "charts", + "SERVERLESS": "serverless", + "SECURITY": "security", + "PRIVATE_ENDPOINT": "private endpoint", + } + + for pattern, category := range categoryPatterns { + if strings.Contains(sku, pattern) { + return category + } + } + return "other" +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadall.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadall.go index 6cad472..d6b3110 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadall.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadall.go @@ -1,19 +1,19 @@ package config import ( - "fmt" + "atlas-sdk-go/internal/errors" ) // LoadAll loads secrets and config from the specified paths func LoadAll(configPath string) (*Secrets, *Config, error) { s, err := LoadSecrets() if err != nil { - return nil, nil, fmt.Errorf("loading secrets: %w", err) + return nil, nil, errors.WithContext(err, "loading secrets") } c, err := LoadConfig(configPath) if err != nil { - return nil, nil, fmt.Errorf("loading config: %w", err) + return nil, nil, errors.WithContext(err, "loading config") } return s, c, nil diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go index e55cd57..a342512 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go @@ -1,12 +1,9 @@ package config import ( + "atlas-sdk-go/internal/errors" "encoding/json" - "fmt" "os" - "strings" - - "atlas-sdk-go/internal" ) type Config struct { @@ -18,31 +15,32 @@ type Config struct { ProcessID string `json:"ATLAS_PROCESS_ID"` } +// LoadConfig reads a JSON configuration file and returns a Config struct func LoadConfig(path string) (*Config, error) { - f, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open config %s: %w", path, err) + if path == "" { + return nil, &errors.ValidationError{ + Message: "configuration file path cannot be empty", + } } - defer internal.SafeClose(f) - var c Config - if err := json.NewDecoder(f).Decode(&c); err != nil { - return nil, fmt.Errorf("decode %s: %w", path, err) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, &errors.NotFoundError{Resource: "configuration file", ID: path} + } + return nil, errors.WithContext(err, "reading configuration file") } - if c.BaseURL == "" { - c.BaseURL = "https://cloud.mongodb.com" - } - if c.HostName == "" { - // Go 1.18+: - if host, _, ok := strings.Cut(c.ProcessID, ":"); ok { - c.HostName = host - } + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, errors.WithContext(err, "parsing configuration file") } - if c.OrgID == "" || c.ProjectID == "" { - return nil, fmt.Errorf("ATLAS_ORG_ID and ATLAS_PROJECT_ID are required") + if config.ProjectID == "" { + return nil, &errors.ValidationError{ + Message: "project ID is required in configuration", + } } - return &c, nil + return &config, nil } diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadenv.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadenv.go index 16dbeb2..aa002f6 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadenv.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadenv.go @@ -1,7 +1,7 @@ package config import ( - "fmt" + "atlas-sdk-go/internal/errors" "os" "strings" ) @@ -32,7 +32,9 @@ func LoadSecrets() (*Secrets, error) { look(EnvSAClientSecret, &s.ServiceAccountSecret) if len(missing) > 0 { - return nil, fmt.Errorf("missing required env vars: %s", strings.Join(missing, ", ")) + return nil, &errors.ValidationError{ + Message: "missing required environment variables: " + strings.Join(missing, ", "), + } } return s, nil } diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/data/export/formats.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/data/export/formats.go new file mode 100644 index 0000000..ca5fd5b --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/data/export/formats.go @@ -0,0 +1,118 @@ +package export + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "atlas-sdk-go/internal/fileutils" +) + +// ToJSON starts a goroutine to encode and write JSON data to a file at the given filePath +func ToJSON(data interface{}, filePath string) error { + if data == nil { + return fmt.Errorf("data cannot be nil") + } + if filePath == "" { + return fmt.Errorf("filePath cannot be empty") + } + + // Create directory if it doesn't exist + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory for JSON export: %w", err) + } + + pr, pw := io.Pipe() + + encodeErrCh := make(chan error, 1) + go func() { + encoder := json.NewEncoder(pw) + encoder.SetIndent("", " ") + err := encoder.Encode(data) + if err != nil { + encodeErrCh <- err + pw.CloseWithError(fmt.Errorf("json encode: %w", err)) + return + } + encodeErrCh <- nil + fileutils.SafeClose(pw) + }() + + writeErr := fileutils.WriteToFile(pr, filePath) + + if encodeErr := <-encodeErrCh; encodeErr != nil { + return fmt.Errorf("json encode: %w", encodeErr) + } + + return writeErr +} + +// ToCSV starts a goroutine to encode and write data in CSV format to a file at the given filePath +func ToCSV(data [][]string, filePath string) error { + if data == nil { + return fmt.Errorf("data cannot be nil") + } + if filePath == "" { + return fmt.Errorf("filePath cannot be empty") + } + // Create directory if it doesn't exist + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory for csv export: %w", err) + } + + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer fileutils.SafeClose(file) + + writer := csv.NewWriter(file) + defer writer.Flush() + + for _, row := range data { + if err := writer.Write(row); err != nil { + return fmt.Errorf("write csv row: %w", err) + } + } + + return nil +} + +// ToCSVWithMapper provides a generic method to convert domain objects to CSV data. +// It exports any slice of data to CSV with custom headers and row mapping (see below for example) +// +// headers := []string{"InvoiceID", "Status", "Created", "AmountBilled"} +// +// rowMapper := func(invoice billing.InvoiceOption) []string { +// return []string{ +// invoice.GetId(), +// invoice.GetStatusName(), +// invoice.GetCreated().Format(time.RFC3339), +// fmt.Sprintf("%.2f", float64(invoice.GetAmountBilledCents())/100.0), +// } +// } +func ToCSVWithMapper[T any](data []T, filePath string, headers []string, rowMapper func(T) []string) error { + if data == nil { + return fmt.Errorf("data cannot be nil") + } + if len(headers) == 0 { + return fmt.Errorf("headers cannot be empty") + } + if rowMapper == nil { + return fmt.Errorf("rowMapper function cannot be nil") + } + + rows := make([][]string, 0, len(data)+1) + rows = append(rows, headers) + + for _, item := range data { + rows = append(rows, rowMapper(item)) + } + + return ToCSV(rows, filePath) +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/errors/utils.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/errors/utils.go new file mode 100644 index 0000000..ca8130b --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/errors/utils.go @@ -0,0 +1,48 @@ +package errors + +import ( + "fmt" + "log" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" +) + +// FormatError formats an error message for a specific operation and entity ID +func FormatError(operation string, entityID string, err error) error { + if apiErr, ok := admin.AsError(err); ok && apiErr.GetDetail() != "" { + return fmt.Errorf("%s for %s: %w: %s", operation, entityID, err, apiErr.GetDetail()) + } + return fmt.Errorf("%s for %s: %w", operation, entityID, err) +} + +// WithContext adds context information to an error +func WithContext(err error, context string) error { + return fmt.Errorf("%s: %w", context, err) +} + +// ValidationError represents an error due to invalid input parameters +type ValidationError struct { + Message string +} + +// Error implements the ValidationError error interface +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation error: %s", e.Message) +} + +// NotFoundError represents an error when a requested resource cannot be found +type NotFoundError struct { + Resource string + ID string +} + +// Error implements the error interface +func (e *NotFoundError) Error() string { + return fmt.Sprintf("resource not found: %s [%s]", e.Resource, e.ID) +} + +// ExitWithError prints an error message with context and exits the program +func ExitWithError(context string, err error) { + log.Fatalf("%s: %v", context, err) + // Note: log.Fatalf calls os.Exit(1) +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/compress.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/compress.go new file mode 100644 index 0000000..464e260 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/compress.go @@ -0,0 +1,33 @@ +package fileutils + +import ( + "compress/gzip" + "fmt" + "os" +) + +// DecompressGzip opens a .gz file and unpacks to specified destination. +func DecompressGzip(srcPath, destPath string) error { + srcFile, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("open %s: %w", srcPath, err) + } + defer SafeClose(srcFile) + + gzReader, err := gzip.NewReader(srcFile) + if err != nil { + return fmt.Errorf("gzip reader %s: %w", srcPath, err) + } + defer SafeClose(gzReader) + + destFile, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("create %s: %w", destPath, err) + } + defer SafeClose(destFile) + + if err := SafeCopy(destFile, gzReader); err != nil { + return fmt.Errorf("decompress to %s: %w", destPath, err) + } + return nil +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go new file mode 100644 index 0000000..0330a0f --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go @@ -0,0 +1,50 @@ +package fileutils + +import ( + "atlas-sdk-go/internal/errors" + "io" + "log" + "os" + "path/filepath" +) + +// WriteToFile copies everything from r into a new file at path. +// It will create or truncate that file. +func WriteToFile(r io.Reader, path string) error { + if r == nil { + return &errors.ValidationError{Message: "reader cannot be nil"} + } + + f, err := os.Create(path) + if err != nil { + return errors.WithContext(err, "create file") + } + defer SafeClose(f) + + if err := SafeCopy(f, r); err != nil { + return errors.WithContext(err, "write to file") + } + return nil +} + +// SafeClose closes c and logs a warning on error +func SafeClose(c io.Closer) { + if c != nil { + if err := c.Close(); err != nil { + log.Printf("warning: close failed: %v", err) + } + } +} + +// SafeCopy copies src → dst and propagates any error +func SafeCopy(dst io.Writer, src io.Reader) error { + if dst == nil || src == nil { + return &errors.ValidationError{Message: "source and destination cannot be nil"} + } + + if _, err := io.Copy(dst, src); err != nil { + return errors.WithContext(err, "copy data") + } + return nil +} + diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/path.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/path.go new file mode 100644 index 0000000..3e3e6a8 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/path.go @@ -0,0 +1,40 @@ +package fileutils + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +// GenerateOutputPath constructs a valid file path based on the given directory, prefix, and optional extension. +// It returns the full path to the generated file with formatted filename or an error if any operation fails. +// +// NOTE: You can define a default global directory for all generated files by setting the ATLAS_DOWNLOADS_DIR environment variable. +func GenerateOutputPath(dir, prefix, extension string) (string, error) { + // If default download directory is set in .env, prepend it to the provided dir + defaultDir := os.Getenv("ATLAS_DOWNLOADS_DIR") + if defaultDir != "" { + dir = filepath.Join(defaultDir, dir) + } + + // Create directory if it doesn't exist + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create output directory: %w", err) + } + + timestamp := time.Now().Format("20060102") + var filename string + if extension == "" { + filename = fmt.Sprintf("%s_%s", prefix, timestamp) + } else { + filename = fmt.Sprintf("%s_%s.%s", prefix, timestamp, extension) + } + + filename = filepath.Clean(filename) + if len(filename) > 255 { + return "", fmt.Errorf("filename exceeds maximum length of 255 characters: %s", filename) + } + + return filepath.Join(dir, filename), nil +} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/fetch.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/fetch.go index b525983..9c56af8 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/fetch.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/fetch.go @@ -1,20 +1,24 @@ package logs import ( + "atlas-sdk-go/internal/errors" "context" - "io" - - "atlas-sdk-go/internal" - "go.mongodb.org/atlas-sdk/v20250219001/admin" + "io" ) -// FetchHostLogs calls the Atlas SDK and returns the raw, compressed log stream. +// FetchHostLogs retrieves logs for a specific host in a given Atlas project. +// Accepts a context for the request, an MonitoringAndLogsApi client instance, and +// the request parameters. +// Returns the raw, compressed log stream or an error if the operation fails. func FetchHostLogs(ctx context.Context, sdk admin.MonitoringAndLogsApi, p *admin.GetHostLogsApiParams) (io.ReadCloser, error) { req := sdk.GetHostLogs(ctx, p.GroupId, p.HostName, p.LogName) rc, _, err := req.Execute() if err != nil { - return nil, internal.FormatAPIError("fetch logs", p.HostName, err) + return nil, errors.FormatError("fetch logs", p.HostName, err) + } + if rc == nil { + return nil, &errors.NotFoundError{Resource: "logs", ID: p.HostName + "/" + p.LogName} } return rc, nil } diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/disk.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/disk.go index 2d8900c..63d4b25 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/disk.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/disk.go @@ -1,26 +1,25 @@ package metrics import ( + "atlas-sdk-go/internal/errors" "context" - "fmt" - - "atlas-sdk-go/internal" "go.mongodb.org/atlas-sdk/v20250219001/admin" ) -// FetchDiskMetrics returns measurements for a specified disk partition +// FetchDiskMetrics returns measurements for a specified disk partition in a MongoDB Atlas project. +// Requires the group ID, process ID, partition name, and parameters for measurement type, granularity, and period. func FetchDiskMetrics(ctx context.Context, sdk admin.MonitoringAndLogsApi, p *admin.GetDiskMeasurementsApiParams) (*admin.ApiMeasurementsGeneralViewAtlas, error) { req := sdk.GetDiskMeasurements(ctx, p.GroupId, p.PartitionName, p.ProcessId) req = req.Granularity(*p.Granularity).Period(*p.Period).M(*p.M) r, _, err := req.Execute() if err != nil { - return nil, internal.FormatAPIError("fetch disk metrics", p.PartitionName, err) + return nil, errors.FormatError("fetch disk metrics", p.ProcessId+"/"+p.PartitionName, err) } + if r == nil || !r.HasMeasurements() || len(r.GetMeasurements()) == 0 { - return nil, fmt.Errorf("no metrics for partition %q on process %q", - p.PartitionName, p.ProcessId) + return nil, &errors.NotFoundError{Resource: "disk metrics", ID: p.ProcessId + "/" + p.PartitionName} } return r, nil } diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/process.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/process.go index feda72a..35a438b 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/process.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/process.go @@ -1,25 +1,23 @@ package metrics import ( + "atlas-sdk-go/internal/errors" "context" - "fmt" - - "atlas-sdk-go/internal" - "go.mongodb.org/atlas-sdk/v20250219001/admin" ) -// FetchProcessMetrics returns measurements for a specified host process +// FetchProcessMetrics returns measurements for a specified host process in a MongoDB Atlas project. +// Requires the group ID, process ID, and parameters for measurement type, granularity, and period. func FetchProcessMetrics(ctx context.Context, sdk admin.MonitoringAndLogsApi, p *admin.GetHostMeasurementsApiParams) (*admin.ApiMeasurementsGeneralViewAtlas, error) { req := sdk.GetHostMeasurements(ctx, p.GroupId, p.ProcessId) req = req.Granularity(*p.Granularity).Period(*p.Period).M(*p.M) r, _, err := req.Execute() if err != nil { - return nil, internal.FormatAPIError("fetch process metrics", p.GroupId, err) + return nil, errors.FormatError("fetch process metrics", p.ProcessId, err) } if r == nil || !r.HasMeasurements() || len(r.GetMeasurements()) == 0 { - return nil, fmt.Errorf("no metrics for process %q", p.ProcessId) + return nil, &errors.NotFoundError{Resource: "process metrics", ID: p.ProcessId} } return r, nil } From 91d91974c93bf4dcd4ebab9b3db143b8f7ea7d70 Mon Sep 17 00:00:00 2001 From: cbullinger Date: Wed, 2 Jul 2025 13:45:37 -0400 Subject: [PATCH 05/11] Run goimports --- .../atlas-sdk-go/examples/billing/historical/main.go | 9 +++++---- .../atlas-sdk-go/examples/billing/line_items/main.go | 3 ++- .../examples/monitoring/metrics_process/main.go | 3 ++- .../go/atlas-sdk-go/internal/auth/client_test.go | 10 ++++++---- .../go/atlas-sdk-go/internal/config/loadconfig.go | 3 ++- .../go/atlas-sdk-go/internal/config/loadenv.go | 3 ++- .../go/atlas-sdk-go/internal/fileutils/io.go | 3 ++- usage-examples/go/atlas-sdk-go/internal/logs/fetch.go | 6 ++++-- .../go/atlas-sdk-go/internal/logs/fetch_test.go | 3 ++- .../go/atlas-sdk-go/internal/metrics/disk.go | 3 ++- .../go/atlas-sdk-go/internal/metrics/process.go | 4 +++- 11 files changed, 32 insertions(+), 18 deletions(-) diff --git a/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go b/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go index 78e2c03..9f07f5e 100644 --- a/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go @@ -5,16 +5,17 @@ package main import ( + "context" + "fmt" + "log" + "time" + "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/data/export" "atlas-sdk-go/internal/errors" "atlas-sdk-go/internal/fileutils" - "context" - "fmt" - "log" - "time" "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" diff --git a/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go b/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go index 1a2110a..a6dbeff 100644 --- a/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go @@ -7,9 +7,10 @@ package main import ( "context" "fmt" - "go.mongodb.org/atlas-sdk/v20250219001/admin" "log" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" diff --git a/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go index f0a7f8c..880d137 100644 --- a/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go @@ -5,12 +5,13 @@ package main import ( - "atlas-sdk-go/internal/errors" "context" "encoding/json" "fmt" "log" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/config" diff --git a/usage-examples/go/atlas-sdk-go/internal/auth/client_test.go b/usage-examples/go/atlas-sdk-go/internal/auth/client_test.go index 5bbdeee..d7adf44 100644 --- a/usage-examples/go/atlas-sdk-go/internal/auth/client_test.go +++ b/usage-examples/go/atlas-sdk-go/internal/auth/client_test.go @@ -1,13 +1,15 @@ package auth_test import ( - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/config" - internalerrors "atlas-sdk-go/internal/errors" "errors" + "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" + internalerrors "atlas-sdk-go/internal/errors" ) func TestNewClient_Success(t *testing.T) { diff --git a/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go b/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go index a342512..0cff7fc 100644 --- a/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go +++ b/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go @@ -1,9 +1,10 @@ package config import ( - "atlas-sdk-go/internal/errors" "encoding/json" "os" + + "atlas-sdk-go/internal/errors" ) type Config struct { diff --git a/usage-examples/go/atlas-sdk-go/internal/config/loadenv.go b/usage-examples/go/atlas-sdk-go/internal/config/loadenv.go index aa002f6..2d31065 100644 --- a/usage-examples/go/atlas-sdk-go/internal/config/loadenv.go +++ b/usage-examples/go/atlas-sdk-go/internal/config/loadenv.go @@ -1,9 +1,10 @@ package config import ( - "atlas-sdk-go/internal/errors" "os" "strings" + + "atlas-sdk-go/internal/errors" ) const ( diff --git a/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go index e136487..9b9ef60 100644 --- a/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go @@ -1,11 +1,12 @@ package fileutils import ( - "atlas-sdk-go/internal/errors" "io" "log" "os" "path/filepath" + + "atlas-sdk-go/internal/errors" ) // WriteToFile copies everything from r into a new file at path. diff --git a/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go b/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go index 9c56af8..a975af0 100644 --- a/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go +++ b/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go @@ -1,10 +1,12 @@ package logs import ( - "atlas-sdk-go/internal/errors" "context" - "go.mongodb.org/atlas-sdk/v20250219001/admin" "io" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/errors" ) // FetchHostLogs retrieves logs for a specific host in a given Atlas project. diff --git a/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go b/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go index ffde967..b373ab7 100644 --- a/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go +++ b/usage-examples/go/atlas-sdk-go/internal/logs/fetch_test.go @@ -1,7 +1,6 @@ package logs import ( - internalerrors "atlas-sdk-go/internal/errors" "context" "errors" "fmt" @@ -9,6 +8,8 @@ import ( "strings" "testing" + internalerrors "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/fileutils" "github.com/stretchr/testify/assert" diff --git a/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go b/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go index 63d4b25..15d6524 100644 --- a/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go +++ b/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go @@ -1,9 +1,10 @@ package metrics import ( - "atlas-sdk-go/internal/errors" "context" + "atlas-sdk-go/internal/errors" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) diff --git a/usage-examples/go/atlas-sdk-go/internal/metrics/process.go b/usage-examples/go/atlas-sdk-go/internal/metrics/process.go index 35a438b..3a801b8 100644 --- a/usage-examples/go/atlas-sdk-go/internal/metrics/process.go +++ b/usage-examples/go/atlas-sdk-go/internal/metrics/process.go @@ -1,9 +1,11 @@ package metrics import ( - "atlas-sdk-go/internal/errors" "context" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/errors" ) // FetchProcessMetrics returns measurements for a specified host process in a MongoDB Atlas project. From 65b6b2a9c0dad0a46575853dc5ea2fbf15222624 Mon Sep 17 00:00:00 2001 From: cbullinger Date: Wed, 2 Jul 2025 13:46:31 -0400 Subject: [PATCH 06/11] Re-generate BH files --- .../go/atlas-sdk-go/main.snippet.get-metrics-prod.go | 3 ++- .../go/atlas-sdk-go/main.snippet.historical-billing.go | 9 +++++---- .../go/atlas-sdk-go/main.snippet.line-items.go | 3 ++- .../project-copy/examples/billing/historical/main.go | 9 +++++---- .../project-copy/examples/billing/line_items/main.go | 3 ++- .../examples/monitoring/metrics_process/main.go | 3 ++- .../project-copy/internal/config/loadconfig.go | 3 ++- .../atlas-sdk-go/project-copy/internal/config/loadenv.go | 3 ++- .../atlas-sdk-go/project-copy/internal/fileutils/io.go | 3 ++- .../go/atlas-sdk-go/project-copy/internal/logs/fetch.go | 6 ++++-- .../atlas-sdk-go/project-copy/internal/metrics/disk.go | 3 ++- .../project-copy/internal/metrics/process.go | 4 +++- 12 files changed, 33 insertions(+), 19 deletions(-) diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go index 74eef51..c2dfbea 100644 --- a/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.get-metrics-prod.go @@ -2,12 +2,13 @@ package main import ( - "atlas-sdk-go/internal/errors" "context" "encoding/json" "fmt" "log" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/config" diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.historical-billing.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.historical-billing.go index 10d2bad..8d43842 100644 --- a/generated-usage-examples/go/atlas-sdk-go/main.snippet.historical-billing.go +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.historical-billing.go @@ -2,16 +2,17 @@ package main import ( + "context" + "fmt" + "log" + "time" + "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/data/export" "atlas-sdk-go/internal/errors" "atlas-sdk-go/internal/fileutils" - "context" - "fmt" - "log" - "time" "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go index 3005aea..de8fe8d 100644 --- a/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go @@ -4,9 +4,10 @@ package main import ( "context" "fmt" - "go.mongodb.org/atlas-sdk/v20250219001/admin" "log" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/historical/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/historical/main.go index 2c8b7bf..7414bbc 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/historical/main.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/historical/main.go @@ -1,16 +1,17 @@ package main import ( + "context" + "fmt" + "log" + "time" + "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" "atlas-sdk-go/internal/data/export" "atlas-sdk-go/internal/errors" "atlas-sdk-go/internal/fileutils" - "context" - "fmt" - "log" - "time" "github.com/joho/godotenv" "go.mongodb.org/atlas-sdk/v20250219001/admin" diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go index 9928696..1dbee55 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go @@ -3,9 +3,10 @@ package main import ( "context" "fmt" - "go.mongodb.org/atlas-sdk/v20250219001/admin" "log" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/billing" "atlas-sdk-go/internal/config" diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go index b96cad9..fe74e79 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go @@ -1,12 +1,13 @@ package main import ( - "atlas-sdk-go/internal/errors" "context" "encoding/json" "fmt" "log" + "atlas-sdk-go/internal/errors" + "atlas-sdk-go/internal/auth" "atlas-sdk-go/internal/config" diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go index a342512..0cff7fc 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadconfig.go @@ -1,9 +1,10 @@ package config import ( - "atlas-sdk-go/internal/errors" "encoding/json" "os" + + "atlas-sdk-go/internal/errors" ) type Config struct { diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadenv.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadenv.go index aa002f6..2d31065 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadenv.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/config/loadenv.go @@ -1,9 +1,10 @@ package config import ( - "atlas-sdk-go/internal/errors" "os" "strings" + + "atlas-sdk-go/internal/errors" ) const ( diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go index 0330a0f..dd2c5d2 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go @@ -1,11 +1,12 @@ package fileutils import ( - "atlas-sdk-go/internal/errors" "io" "log" "os" "path/filepath" + + "atlas-sdk-go/internal/errors" ) // WriteToFile copies everything from r into a new file at path. diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/fetch.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/fetch.go index 9c56af8..a975af0 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/fetch.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/fetch.go @@ -1,10 +1,12 @@ package logs import ( - "atlas-sdk-go/internal/errors" "context" - "go.mongodb.org/atlas-sdk/v20250219001/admin" "io" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/errors" ) // FetchHostLogs retrieves logs for a specific host in a given Atlas project. diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/disk.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/disk.go index 63d4b25..15d6524 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/disk.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/disk.go @@ -1,9 +1,10 @@ package metrics import ( - "atlas-sdk-go/internal/errors" "context" + "atlas-sdk-go/internal/errors" + "go.mongodb.org/atlas-sdk/v20250219001/admin" ) diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/process.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/process.go index 35a438b..3a801b8 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/process.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/process.go @@ -1,9 +1,11 @@ package metrics import ( - "atlas-sdk-go/internal/errors" "context" + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/errors" ) // FetchProcessMetrics returns measurements for a specified host process in a MongoDB Atlas project. From efa6cc3f1482076d3deeaab819bed104d6cc2e43 Mon Sep 17 00:00:00 2001 From: cbullinger Date: Wed, 2 Jul 2025 13:56:41 -0400 Subject: [PATCH 07/11] Fix copy state tag and re-generate --- .../project-copy/configs/ignore.config.json | 7 ------ .../examples/monitoring/metrics_disk/main.go | 22 ------------------- .../examples/monitoring/metrics_disk/main.go | 2 +- .../internal/data/export/formats_test.go | 14 ++---------- 4 files changed, 3 insertions(+), 42 deletions(-) delete mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/configs/ignore.config.json diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/configs/ignore.config.json b/generated-usage-examples/go/atlas-sdk-go/project-copy/configs/ignore.config.json deleted file mode 100644 index 3818a1a..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/configs/ignore.config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "MONGODB_ATLAS_BASE_URL": "https://cloud.mongodb.com", - "ATLAS_ORG_ID": "5bfda007553855125605a5cf", - "ATLAS_PROJECT_ID": "5f60207f14dfb25d23101102", - "ATLAS_CLUSTER_NAME": "Cluster0", - "ATLAS_PROCESS_ID": "cluster0-shard-00-00.nr3ko.mongodb.net:27017" -} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_disk/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_disk/main.go index 615abe4..9ccedb1 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_disk/main.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_disk/main.go @@ -54,25 +54,3 @@ func main() { fmt.Println(string(out)) } -// NOTE: INTERNAL -// ** OUTPUT EXAMPLE ** -// { -// "measurements": [ -// { -// "name": "DISK_PARTITION_SPACE_FREE", -// "granularity": "P1D", -// "period": "P1D", -// "values": [ -// { -// "timestamp": "2023-10-01T00:00:00Z", -// "value": 1234567890 -// }, -// { -// "timestamp": "2023-10-02T00:00:00Z", -// "value": 1234567890 -// } -// ] -// }, -// ... -// ] -// } diff --git a/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_disk/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_disk/main.go index 26f43ab..74fb9a8 100644 --- a/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_disk/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_disk/main.go @@ -59,7 +59,7 @@ func main() { } // :snippet-end: [get-metrics-dev] -// :state-remove-start: [copy] +// :state-remove-start: copy // NOTE: INTERNAL // ** OUTPUT EXAMPLE ** // { diff --git a/usage-examples/go/atlas-sdk-go/internal/data/export/formats_test.go b/usage-examples/go/atlas-sdk-go/internal/data/export/formats_test.go index b5f4283..e2ce586 100644 --- a/usage-examples/go/atlas-sdk-go/internal/data/export/formats_test.go +++ b/usage-examples/go/atlas-sdk-go/internal/data/export/formats_test.go @@ -22,16 +22,13 @@ func TestToJSON(t *testing.T) { t.Parallel() t.Run("Successfully writes JSON to file", func(t *testing.T) { - // Setup tempDir := t.TempDir() filePath := filepath.Join(tempDir, "test.json") testData := TestStruct{ID: 1, Name: "Test", Value: 99.99} - // Execute err := ToJSON(testData, filePath) require.NoError(t, err) - // Verify fileContent, err := os.ReadFile(filePath) require.NoError(t, err) @@ -57,20 +54,17 @@ func TestToJSON(t *testing.T) { }) t.Run("Creates directory structure if needed", func(t *testing.T) { - // Setup tempDir := t.TempDir() dirPath := filepath.Join(tempDir, "subdir1", "subdir2") filePath := filepath.Join(dirPath, "test.json") testData := TestStruct{ID: 1, Name: "Test", Value: 99.99} - // Execute err := ToJSON(testData, filePath) require.NoError(t, err) - // Verify directory was created dirInfo, err := os.Stat(dirPath) require.NoError(t, err) - assert.True(t, dirInfo.IsDir()) + assert.True(t, dirInfo.IsDir(), "Expected directory to be created") }) } @@ -86,11 +80,9 @@ func TestToCSV(t *testing.T) { {"1", "Test", "99.99"}, } - // Execute err := ToCSV(testData, filePath) require.NoError(t, err) - // Verify file, err := os.Open(filePath) require.NoError(t, err) defer file.Close() @@ -99,7 +91,7 @@ func TestToCSV(t *testing.T) { rows, err := reader.ReadAll() require.NoError(t, err) - assert.Equal(t, testData, rows) + assert.Equal(t, testData, rows, "Expected CSV rows to match input data") }) t.Run("Returns error for nil data", func(t *testing.T) { @@ -135,11 +127,9 @@ func TestToCSVWithMapper(t *testing.T) { } } - // Execute err := ToCSVWithMapper(testData, filePath, headers, rowMapper) require.NoError(t, err) - // Verify file, err := os.Open(filePath) require.NoError(t, err) defer file.Close() From f09e10c40c42095dd6a0b1eaea236225dec74af4 Mon Sep 17 00:00:00 2001 From: cbullinger Date: Mon, 7 Jul 2025 14:53:34 -0400 Subject: [PATCH 08/11] Apply feedback and regenerate files --- .../atlas-sdk-go/main.snippet.line-items.go | 4 ++ .../project-copy/cmd/get_linked_orgs/main.go | 57 ----------------- .../project-copy/cmd/get_logs/main.go | 61 ------------------- .../project-copy/cmd/get_metrics_disk/main.go | 47 -------------- .../cmd/get_metrics_process/main.go | 52 ---------------- .../examples/billing/line_items/main.go | 4 ++ .../project-copy/internal/billing/crossorg.go | 43 ------------- .../internal/billing/linkedorgs.go | 25 -------- .../project-copy/internal/fileutils/io.go | 1 - .../project-copy/internal/logs/file.go | 24 -------- .../project-copy/internal/logs/gzip.go | 35 ----------- .../project-copy/internal/utils.go | 36 ----------- .../examples/billing/line_items/main.go | 4 ++ .../go/atlas-sdk-go/internal/fileutils/io.go | 4 +- .../go/atlas-sdk-go/scripts/bluehawk.sh | 9 +++ 15 files changed, 23 insertions(+), 383 deletions(-) delete mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_linked_orgs/main.go delete mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_logs/main.go delete mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_metrics_disk/main.go delete mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_metrics_process/main.go delete mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/crossorg.go delete mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/linkedorgs.go delete mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/file.go delete mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/gzip.go delete mode 100644 generated-usage-examples/go/atlas-sdk-go/project-copy/internal/utils.go diff --git a/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go b/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go index de8fe8d..3247f42 100644 --- a/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go @@ -45,6 +45,10 @@ func main() { errors.ExitWithError(fmt.Sprintf("Failed to retrieve pending invoices for %s", p.OrgId), err) } + if len(details) == 0 { + fmt.Printf("No pending invoices found for organization: %s\n", p.OrgId) + return + } fmt.Printf("Found %d line items in pending invoices\n", len(details)) // Export invoice data to be used in other systems or for reporting diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_linked_orgs/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_linked_orgs/main.go deleted file mode 100644 index f93ae38..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_linked_orgs/main.go +++ /dev/null @@ -1,57 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/billing" - "atlas-sdk-go/internal/config" -) - -func main() { - _ = godotenv.Load() - - secrets, cfg, err := config.LoadAll("configs/config.json") - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - sdk, err := auth.NewClient(cfg, secrets) - if err != nil { - log.Fatalf("Failed to initialize client: %v", err) - } - - ctx := context.Background() - params := &admin.ListInvoicesApiParams{ - OrgId: cfg.OrgID, - } - - fmt.Printf("Fetching cross-org billing info for organization: %s\n", params.OrgId) - results, err := billing.GetCrossOrgBilling(ctx, sdk.InvoicesApi, params) - if err != nil { - log.Fatalf("Failed to retrieve invoices: %v", err) - } - if len(results) == 0 { - fmt.Println("No invoices found for the billing organization") - return - } - - linkedOrgs, err := billing.GetLinkedOrgs(ctx, sdk.InvoicesApi, params) - if err != nil { - log.Fatalf("Failed to retrieve linked organizations: %v", err) - } - if len(linkedOrgs) == 0 { - fmt.Println("No linked organizations found for the billing org") - return - } - fmt.Println("Linked organizations:") - for i, org := range linkedOrgs { - fmt.Printf(" %d. %v\n", i+1, org) - } -} - diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_logs/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_logs/main.go deleted file mode 100644 index 99ec158..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_logs/main.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "os" - "path/filepath" - "time" - - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - - "atlas-sdk-go/internal" - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/config" - "atlas-sdk-go/internal/logs" -) - -func main() { - _ = godotenv.Load() - secrets, cfg, err := config.LoadAll("configs/config.json") - if err != nil { - log.Fatalf("config load: %v", err) - } - - sdk, err := auth.NewClient(cfg, secrets) - if err != nil { - log.Fatalf("client init: %v", err) - } - - ctx := context.Background() - p := &admin.GetHostLogsApiParams{ - GroupId: cfg.ProjectID, - HostName: cfg.HostName, - LogName: "mongodb", - } - ts := time.Now().Format("20060102_150405") - base := fmt.Sprintf("%s_%s_%s", p.HostName, p.LogName, ts) - outDir := "logs" - os.MkdirAll(outDir, 0o755) - gzPath := filepath.Join(outDir, base+".gz") - txtPath := filepath.Join(outDir, base+".txt") - - rc, err := logs.FetchHostLogs(ctx, sdk.MonitoringAndLogsApi, p) - if err != nil { - log.Fatalf("download logs: %v", err) - } - defer internal.SafeClose(rc) - - if err := logs.WriteToFile(rc, gzPath); err != nil { - log.Fatalf("save gz: %v", err) - } - fmt.Println("Saved compressed log to", gzPath) - - if err := logs.DecompressGzip(gzPath, txtPath); err != nil { - log.Fatalf("decompress: %v", err) - } - fmt.Println("Uncompressed log to", txtPath) -} - diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_metrics_disk/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_metrics_disk/main.go deleted file mode 100644 index 912cc75..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_metrics_disk/main.go +++ /dev/null @@ -1,47 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "log" - - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/config" - "atlas-sdk-go/internal/metrics" -) - -func main() { - _ = godotenv.Load() - secrets, cfg, err := config.LoadAll("configs/config.json") - if err != nil { - log.Fatalf("config load: %v", err) - } - - sdk, err := auth.NewClient(cfg, secrets) - if err != nil { - log.Fatalf("client init: %v", err) - } - - ctx := context.Background() - p := &admin.GetDiskMeasurementsApiParams{ - GroupId: cfg.ProjectID, - ProcessId: cfg.ProcessID, - PartitionName: "data", - M: &[]string{"DISK_PARTITION_SPACE_FREE", "DISK_PARTITION_SPACE_USED"}, - Granularity: admin.PtrString("P1D"), - Period: admin.PtrString("P1D"), - } - - view, err := metrics.FetchDiskMetrics(ctx, sdk.MonitoringAndLogsApi, p) - if err != nil { - log.Fatalf("disk metrics: %v", err) - } - - out, _ := json.MarshalIndent(view, "", " ") - fmt.Println(string(out)) -} - diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_metrics_process/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_metrics_process/main.go deleted file mode 100644 index 26fb4a3..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_metrics_process/main.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "log" - - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" - - "atlas-sdk-go/internal/auth" - "atlas-sdk-go/internal/config" - "atlas-sdk-go/internal/metrics" -) - -func main() { - _ = godotenv.Load() - secrets, cfg, err := config.LoadAll("configs/config.json") - if err != nil { - log.Fatalf("config load: %v", err) - } - - sdk, err := auth.NewClient(cfg, secrets) - if err != nil { - log.Fatalf("client init: %v", err) - } - - ctx := context.Background() - p := &admin.GetHostMeasurementsApiParams{ - GroupId: cfg.ProjectID, - ProcessId: cfg.ProcessID, - M: &[]string{ - "OPCOUNTER_INSERT", "OPCOUNTER_QUERY", "OPCOUNTER_UPDATE", "TICKETS_AVAILABLE_READS", - "TICKETS_AVAILABLE_WRITE", "CONNECTIONS", "QUERY_TARGETING_SCANNED_OBJECTS_PER_RETURNED", - "QUERY_TARGETING_SCANNED_PER_RETURNED", "SYSTEM_CPU_GUEST", "SYSTEM_CPU_IOWAIT", - "SYSTEM_CPU_IRQ", "SYSTEM_CPU_KERNEL", "SYSTEM_CPU_NICE", "SYSTEM_CPU_SOFTIRQ", - "SYSTEM_CPU_STEAL", "SYSTEM_CPU_USER", - }, - Granularity: admin.PtrString("PT1H"), - Period: admin.PtrString("P7D"), - } - - view, err := metrics.FetchProcessMetrics(ctx, sdk.MonitoringAndLogsApi, p) - if err != nil { - log.Fatalf("process metrics: %v", err) - } - - out, _ := json.MarshalIndent(view, "", " ") - fmt.Println(string(out)) -} - diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go index 1dbee55..634fac2 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go @@ -44,6 +44,10 @@ func main() { errors.ExitWithError(fmt.Sprintf("Failed to retrieve pending invoices for %s", p.OrgId), err) } + if len(details) == 0 { + fmt.Printf("No pending invoices found for organization: %s\n", p.OrgId) + return + } fmt.Printf("Found %d line items in pending invoices\n", len(details)) // Export invoice data to be used in other systems or for reporting diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/crossorg.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/crossorg.go deleted file mode 100644 index e68edbd..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/crossorg.go +++ /dev/null @@ -1,43 +0,0 @@ -package billing - -import ( - "context" - - "go.mongodb.org/atlas-sdk/v20250219001/admin" - - "atlas-sdk-go/internal" -) - -// GetCrossOrgBilling returns all invoices for the billing organization and any linked organizations. -// NOTE: Organization Billing Admin or Organization Owner role required to view linked invoices. -func GetCrossOrgBilling(ctx context.Context, sdk admin.InvoicesApi, p *admin.ListInvoicesApiParams) (map[string][]admin.BillingInvoiceMetadata, error) { - req := sdk.ListInvoices(ctx, p.OrgId) - - r, _, err := req.Execute() - - if err != nil { - return nil, internal.FormatAPIError("list invoices", p.OrgId, err) - } - - crossOrgBilling := make(map[string][]admin.BillingInvoiceMetadata) - if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { - return crossOrgBilling, nil - } - - crossOrgBilling[p.OrgId] = r.GetResults() - for _, invoice := range r.GetResults() { - if !invoice.HasLinkedInvoices() || len(invoice.GetLinkedInvoices()) == 0 { - continue - } - - for _, linkedInvoice := range invoice.GetLinkedInvoices() { - orgID := linkedInvoice.GetOrgId() - if orgID == "" { - continue - } - crossOrgBilling[orgID] = append(crossOrgBilling[orgID], linkedInvoice) - } - } - - return crossOrgBilling, nil -} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/linkedorgs.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/linkedorgs.go deleted file mode 100644 index 7d35a83..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/linkedorgs.go +++ /dev/null @@ -1,25 +0,0 @@ -package billing - -import ( - "context" - "fmt" - - "go.mongodb.org/atlas-sdk/v20250219001/admin" -) - -// GetLinkedOrgs returns all linked organizations for a given billing organization. -func GetLinkedOrgs(ctx context.Context, sdk admin.InvoicesApi, p *admin.ListInvoicesApiParams) ([]string, error) { - invoices, err := GetCrossOrgBilling(ctx, sdk, p) - if err != nil { - return nil, fmt.Errorf("get cross-org billing: %w", err) - } - - var linkedOrgs []string - for orgID := range invoices { - if orgID != p.OrgId { - linkedOrgs = append(linkedOrgs, orgID) - } - } - - return linkedOrgs, nil -} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go index dd2c5d2..542f0ad 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go @@ -4,7 +4,6 @@ import ( "io" "log" "os" - "path/filepath" "atlas-sdk-go/internal/errors" ) diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/file.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/file.go deleted file mode 100644 index b68f17f..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/file.go +++ /dev/null @@ -1,24 +0,0 @@ -package logs - -import ( - "fmt" - "io" - "os" - - "atlas-sdk-go/internal" -) - -// WriteToFile copies everything from r into a new file at path. -// It will create or truncate that file. -func WriteToFile(r io.Reader, path string) error { - f, err := os.Create(path) - if err != nil { - return fmt.Errorf("create %s: %w", path, err) - } - defer internal.SafeClose(f) - - if err := internal.SafeCopy(f, r); err != nil { - return fmt.Errorf("write %s: %w", path, err) - } - return nil -} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/gzip.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/gzip.go deleted file mode 100644 index 13901f6..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/gzip.go +++ /dev/null @@ -1,35 +0,0 @@ -package logs - -import ( - "compress/gzip" - "fmt" - "os" - - "atlas-sdk-go/internal" -) - -// DecompressGzip opens a .gz file and unpacks to specified destination. -func DecompressGzip(srcPath, destPath string) error { - srcFile, err := os.Open(srcPath) - if err != nil { - return fmt.Errorf("open %s: %w", srcPath, err) - } - defer internal.SafeClose(srcFile) - - gzReader, err := gzip.NewReader(srcFile) - if err != nil { - return fmt.Errorf("gzip reader %s: %w", srcPath, err) - } - defer internal.SafeClose(gzReader) - - destFile, err := os.Create(destPath) - if err != nil { - return fmt.Errorf("create %s: %w", destPath, err) - } - defer internal.SafeClose(destFile) - - if err := internal.SafeCopy(destFile, gzReader); err != nil { - return fmt.Errorf("decompress to %s: %w", destPath, err) - } - return nil -} diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/utils.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/utils.go deleted file mode 100644 index a767281..0000000 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/utils.go +++ /dev/null @@ -1,36 +0,0 @@ -package internal - -import ( - "fmt" - "io" - "log" - - "go.mongodb.org/atlas-sdk/v20250219001/admin" -) - -// FormatAPIError formats an error returned by the Atlas API with additional context. -func FormatAPIError(operation string, params interface{}, err error) error { - if apiErr, ok := admin.AsError(err); ok && apiErr.GetDetail() != "" { - return fmt.Errorf("%s %v: %w: %s", operation, params, err, apiErr.GetDetail()) - } - return fmt.Errorf("%s %v: %w", operation, params, err) -} - -// SafeClose closes c and logs a warning on error. -func SafeClose(c io.Closer) { - if c != nil { - if err := c.Close(); err != nil { - log.Printf("warning: close failed: %v", err) - } - } -} - -// SafeCopy copies src → dst and propagates any error (after logging). -func SafeCopy(dst io.Writer, src io.Reader) error { - if _, err := io.Copy(dst, src); err != nil { - log.Printf("warning: copy failed: %v", err) - return err - } - return nil -} - diff --git a/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go b/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go index a6dbeff..5a96db1 100644 --- a/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go @@ -48,6 +48,10 @@ func main() { errors.ExitWithError(fmt.Sprintf("Failed to retrieve pending invoices for %s", p.OrgId), err) } + if len(details) == 0 { + fmt.Printf("No pending invoices found for organization: %s\n", p.OrgId) + return + } fmt.Printf("Found %d line items in pending invoices\n", len(details)) // Export invoice data to be used in other systems or for reporting diff --git a/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go index 9b9ef60..7a97b37 100644 --- a/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go @@ -4,7 +4,7 @@ import ( "io" "log" "os" - "path/filepath" + "path/filepath" // :remove: "atlas-sdk-go/internal/errors" ) @@ -52,7 +52,7 @@ func SafeCopy(dst io.Writer, src io.Reader) error { // :remove-start: // SafeDelete removes files generated in the specified directory -// NOTE: INTERNAL ONLY FUNCTION +// NOTE: INTERNAL ONLY FUNCTION; remove this and "path/filepath" import from Bluehawked files func SafeDelete(dir string) error { err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { diff --git a/usage-examples/go/atlas-sdk-go/scripts/bluehawk.sh b/usage-examples/go/atlas-sdk-go/scripts/bluehawk.sh index ad52dd3..b103389 100755 --- a/usage-examples/go/atlas-sdk-go/scripts/bluehawk.sh +++ b/usage-examples/go/atlas-sdk-go/scripts/bluehawk.sh @@ -148,6 +148,15 @@ if [[ "$CMD" == "copy" ]] && [[ ${#RENAME_ARGS[@]} -gt 0 ]]; then CMD_ARGS+=("${RENAME_ARGS[@]}") fi +# Clean destination directory for copy command +if [[ "$CMD" == "copy" ]]; then + echo "Cleaning destination directory: $OUTPUT_DIR" + if [[ -d "$OUTPUT_DIR" ]]; then + rm -rf "$OUTPUT_DIR" + fi + mkdir -p "$OUTPUT_DIR" +fi + # Add input directory CMD_ARGS+=("$INPUT_DIR") From 1516e1f4a3e4782681bef94fa1b4988c41a9fa3c Mon Sep 17 00:00:00 2001 From: cbullinger Date: Wed, 9 Jul 2025 09:05:47 -0400 Subject: [PATCH 09/11] Fix SKU category mapping --- .../internal/billing/sku_classifier.go | 73 +++++++++++++------ .../internal/billing/sku_classifier.go | 73 +++++++++++++------ .../internal/billing/sku_classifier_test.go | 59 ++++++++------- 3 files changed, 132 insertions(+), 73 deletions(-) diff --git a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/sku_classifier.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/sku_classifier.go index c65dc1a..57e39ff 100644 --- a/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/sku_classifier.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/sku_classifier.go @@ -6,11 +6,13 @@ import ( // determineProvider identifies the cloud provider based on SKU func determineProvider(sku string) string { - if strings.Contains(sku, "AWS") { + uppercaseSku := strings.ToUpper(sku) + + if strings.Contains(uppercaseSku, "AWS") { return "AWS" - } else if strings.Contains(sku, "AZURE") { + } else if strings.Contains(strings.ToUpper(sku), "AZURE") { return "AZURE" - } else if strings.Contains(sku, "GCP") { + } else if strings.Contains(strings.ToUpper(sku), "GCP") { return "GCP" } return "n/a" @@ -18,36 +20,59 @@ func determineProvider(sku string) string { // determineInstance extracts the instance type from SKU func determineInstance(sku string) string { - parts := strings.Split(sku, "_INSTANCE_") + uppercaseSku := strings.ToUpper(sku) + + parts := strings.Split(uppercaseSku, "_INSTANCE_") if len(parts) > 1 { return parts[1] } return "non-instance" } -// determineCategory categorizes the SKU +// determineCategory identifies the service category based on SKU func determineCategory(sku string) string { - categoryPatterns := map[string]string{ - "_INSTANCE": "instances", - "BACKUP": "backup", - "PIT_RESTORE": "backup", - "DATA_TRANSFER": "data xfer", - "STORAGE": "storage", - "BI_CONNECTOR": "bi-connector", - "DATA_LAKE": "data lake", - "AUDITING": "audit", - "ATLAS_SUPPORT": "support", - "FREE_SUPPORT": "free support", - "CHARTS": "charts", - "SERVERLESS": "serverless", - "SECURITY": "security", - "PRIVATE_ENDPOINT": "private endpoint", + uppercaseSku := strings.ToUpper(sku) + + // Category patterns are defined in order of specificity + categoryPatterns := []struct { + pattern string + category string + }{ + {"CLASSIC_BACKUP", "Legacy Backup"}, + {"BACKUP_SNAPSHOT", "Backup"}, + {"BACKUP_DOWNLOAD", "Backup"}, + {"BACKUP_STORAGE", "Backup"}, + {"DATA_FEDERATION", "Atlas Data Federation"}, + {"DATA_LAKE", "Atlas Data Federation"}, + {"STREAM_PROCESSING", "Atlas Stream Processing"}, + {"PRIVATE_ENDPOINT", "Data Transfer"}, + {"DATA_TRANSFER", "Data Transfer"}, + {"SNAPSHOT_COPY", "Backup"}, + {"SNAPSHOT_EXPORT", "Backup"}, + {"OBJECT_STORAGE", "Backup"}, + {"PIT_RESTORE", "Backup"}, + {"BI_CONNECTOR", "BI Connector"}, + {"CHARTS", "Charts"}, + {"INSTANCE", "Clusters"}, + {"MMS", "Cloud Manager Standard/Premium"}, + {"CLASSIC_COUPON", "Credits"}, + {"CREDIT", "Credits"}, + {"MINIMUM_CHARGE", "Credits"}, + {"FLEX_CONSULTING", "Flex Consulting"}, + {"AUDITING", "Premium Features"}, + {"ADVANCED_SECURITY", "Premium Features"}, + {"SERVERLESS", "Serverless Instances"}, + {"STORAGE", "Storage"}, + {"ENTITLEMENTS", "Support"}, + {"FREE_SUPPORT", "Support"}, + {"REALM", "App Services"}, + {"STITCH", "App Services"}, } - for pattern, category := range categoryPatterns { - if strings.Contains(sku, pattern) { - return category + for _, pc := range categoryPatterns { + if strings.Index(uppercaseSku, pc.pattern) != -1 { + return pc.category } } - return "other" + return "Other" } diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier.go b/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier.go index c65dc1a..57e39ff 100644 --- a/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier.go +++ b/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier.go @@ -6,11 +6,13 @@ import ( // determineProvider identifies the cloud provider based on SKU func determineProvider(sku string) string { - if strings.Contains(sku, "AWS") { + uppercaseSku := strings.ToUpper(sku) + + if strings.Contains(uppercaseSku, "AWS") { return "AWS" - } else if strings.Contains(sku, "AZURE") { + } else if strings.Contains(strings.ToUpper(sku), "AZURE") { return "AZURE" - } else if strings.Contains(sku, "GCP") { + } else if strings.Contains(strings.ToUpper(sku), "GCP") { return "GCP" } return "n/a" @@ -18,36 +20,59 @@ func determineProvider(sku string) string { // determineInstance extracts the instance type from SKU func determineInstance(sku string) string { - parts := strings.Split(sku, "_INSTANCE_") + uppercaseSku := strings.ToUpper(sku) + + parts := strings.Split(uppercaseSku, "_INSTANCE_") if len(parts) > 1 { return parts[1] } return "non-instance" } -// determineCategory categorizes the SKU +// determineCategory identifies the service category based on SKU func determineCategory(sku string) string { - categoryPatterns := map[string]string{ - "_INSTANCE": "instances", - "BACKUP": "backup", - "PIT_RESTORE": "backup", - "DATA_TRANSFER": "data xfer", - "STORAGE": "storage", - "BI_CONNECTOR": "bi-connector", - "DATA_LAKE": "data lake", - "AUDITING": "audit", - "ATLAS_SUPPORT": "support", - "FREE_SUPPORT": "free support", - "CHARTS": "charts", - "SERVERLESS": "serverless", - "SECURITY": "security", - "PRIVATE_ENDPOINT": "private endpoint", + uppercaseSku := strings.ToUpper(sku) + + // Category patterns are defined in order of specificity + categoryPatterns := []struct { + pattern string + category string + }{ + {"CLASSIC_BACKUP", "Legacy Backup"}, + {"BACKUP_SNAPSHOT", "Backup"}, + {"BACKUP_DOWNLOAD", "Backup"}, + {"BACKUP_STORAGE", "Backup"}, + {"DATA_FEDERATION", "Atlas Data Federation"}, + {"DATA_LAKE", "Atlas Data Federation"}, + {"STREAM_PROCESSING", "Atlas Stream Processing"}, + {"PRIVATE_ENDPOINT", "Data Transfer"}, + {"DATA_TRANSFER", "Data Transfer"}, + {"SNAPSHOT_COPY", "Backup"}, + {"SNAPSHOT_EXPORT", "Backup"}, + {"OBJECT_STORAGE", "Backup"}, + {"PIT_RESTORE", "Backup"}, + {"BI_CONNECTOR", "BI Connector"}, + {"CHARTS", "Charts"}, + {"INSTANCE", "Clusters"}, + {"MMS", "Cloud Manager Standard/Premium"}, + {"CLASSIC_COUPON", "Credits"}, + {"CREDIT", "Credits"}, + {"MINIMUM_CHARGE", "Credits"}, + {"FLEX_CONSULTING", "Flex Consulting"}, + {"AUDITING", "Premium Features"}, + {"ADVANCED_SECURITY", "Premium Features"}, + {"SERVERLESS", "Serverless Instances"}, + {"STORAGE", "Storage"}, + {"ENTITLEMENTS", "Support"}, + {"FREE_SUPPORT", "Support"}, + {"REALM", "App Services"}, + {"STITCH", "App Services"}, } - for pattern, category := range categoryPatterns { - if strings.Contains(sku, pattern) { - return category + for _, pc := range categoryPatterns { + if strings.Index(uppercaseSku, pc.pattern) != -1 { + return pc.category } } - return "other" + return "Other" } diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier_test.go b/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier_test.go index 4e703bc..5c4ba4b 100644 --- a/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier_test.go +++ b/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier_test.go @@ -12,11 +12,12 @@ func TestDetermineProvider(t *testing.T) { sku string expected string }{ - {"AWS SKU", "MONGODB_ATLAS_AWS_INSTANCE_M10", "AWS"}, - {"AZURE SKU", "MONGODB_ATLAS_AZURE_INSTANCE_M20", "AZURE"}, - {"GCP SKU", "MONGODB_ATLAS_GCP_INSTANCE_M30", "GCP"}, - {"Unknown provider", "MONGODB_ATLAS_INSTANCE_M40", "n/a"}, + {"AWS SKU", "NDS_AWS_INSTANCE_M10", "AWS"}, + {"AZURE SKU", "NDS_AZURE_INSTANCE_M20", "AZURE"}, + {"GCP SKU", "NDS_GCP_INSTANCE_M30", "GCP"}, + {"Unknown provider", "NDS_INSTANCE_M40", "n/a"}, {"Empty SKU", "", "n/a"}, + {"Mixed case", "nds_Aws_instance", "AWS"}, } for _, tc := range tests { @@ -33,11 +34,11 @@ func TestDetermineInstance(t *testing.T) { sku string expected string }{ - {"Basic instance", "MONGODB_ATLAS_AWS_INSTANCE_M10", "M10"}, - {"Complex instance name", "MONGODB_ATLAS_AWS_INSTANCE_M30_NVME", "M30_NVME"}, - {"No instance marker", "MONGODB_ATLAS_BACKUP", "non-instance"}, + {"Basic instance", "NDS_AWS_INSTANCE_M10", "M10"}, + {"Complex instance name", "NDS_AWS_INSTANCE_M40_NVME", "M40_NVME"}, + {"Serverless instance", "NDS_AWS_SERVERLESS_RPU", "non-instance"}, {"Empty SKU", "", "non-instance"}, - {"Multiple instance markers", "INSTANCE_M10_INSTANCE_M20", "M20"}, + {"Search instance", "NDS_AWS_SEARCH_INSTANCE_S20_COMPUTE_NVME", "S20_COMPUTE_NVME"}, } for _, tc := range tests { @@ -54,23 +55,31 @@ func TestDetermineCategory(t *testing.T) { sku string expected string }{ - {"Instance category", "MONGODB_ATLAS_AWS_INSTANCE_M10", "instances"}, - {"Backup category", "MONGODB_ATLAS_BACKUP", "backup"}, - {"PIT Restore", "MONGODB_ATLAS_PIT_RESTORE", "backup"}, - {"Data Transfer", "MONGODB_ATLAS_DATA_TRANSFER", "data xfer"}, - {"Storage", "MONGODB_ATLAS_STORAGE", "storage"}, - {"BI Connector", "MONGODB_ATLAS_BI_CONNECTOR", "bi-connector"}, - {"Data Lake", "MONGODB_ATLAS_DATA_LAKE", "data lake"}, - {"Auditing", "MONGODB_ATLAS_AUDITING", "audit"}, - {"Atlas Support", "MONGODB_ATLAS_SUPPORT", "support"}, - {"Free Support", "MONGODB_ATLAS_FREE_SUPPORT", "free support"}, - {"Charts", "MONGODB_ATLAS_CHARTS", "charts"}, - {"Serverless", "MONGODB_ATLAS_SERVERLESS", "serverless"}, - {"Security", "MONGODB_ATLAS_SECURITY", "security"}, - {"Private Endpoint", "MONGODB_ATLAS_PRIVATE_ENDPOINT", "private endpoint"}, - {"Other category", "MONGODB_ATLAS_UNKNOWN", "other"}, - {"Empty SKU", "", "other"}, - {"Multiple patterns", "MONGODB_ATLAS_BACKUP_STORAGE", "backup"}, // First match should win + {"Instance category", "NDS_AWS_INSTANCE_M10", "Clusters"}, + {"Backup category", "NDS_AWS_BACKUP_SNAPSHOT_STORAGE", "Backup"}, + {"PIT Restore", "NDS_AWS_PIT_RESTORE_STORAGE", "Backup"}, + {"Legacy Backup", "CLASSIC_BACKUP_STORAGE", "Legacy Backup"}, + {"Data Transfer", "NDS_AWS_DATA_TRANSFER_SAME_REGION", "Data Transfer"}, + {"Storage", "NDS_AWS_STORAGE_STANDARD", "Storage"}, + {"BI Connector", "NDS_BI_CONNECTOR", "BI Connector"}, + {"Data Lake", "DATA_LAKE_AWS_DATA_SCANNED", "Atlas Data Federation"}, + {"Data Federation", "DATA_FEDERATION_AZURE_DATA_SCANNED", "Atlas Data Federation"}, + {"Auditing", "NDS_ENTERPRISE_AUDITING", "Premium Features"}, + {"Atlas Support", "NDS_ENTITLEMENTS", "Support"}, + {"Free Support", "NDS_FREE_SUPPORT", "Support"}, + {"Charts", "CHARTS_DATA_DOWNLOADED", "Charts"}, + {"Serverless", "NDS_AWS_SERVERLESS_RPU", "Serverless Instances"}, + {"Security", "NDS_ADVANCED_SECURITY", "Premium Features"}, + {"Private Endpoint", "NDS_AWS_PRIVATE_ENDPOINT", "Data Transfer"}, + {"Cloud Manager", "MMS_PREMIUM", "Cloud Manager Standard/Premium"}, + {"Stream Processing", "NDS_AWS_STREAM_PROCESSING_INSTANCE_SP10", "Atlas Stream Processing"}, + {"App Services", "REALM_APP_REQUESTS", "App Services"}, + {"Credits", "CREDIT", "Credits"}, + {"Flex Consulting", "MONGODB_FLEX_CONSULTING", "Flex Consulting"}, + {"Other category", "UNKNOWN_SKU_TYPE", "Other"}, + {"Empty SKU", "", "Other"}, + {"Overlapping patterns", "NDS_AWS_SERVERLESS_STORAGE", "Serverless Instances"}, + {"Conflicting patterns", "NDS_AZURE_DATA_LAKE_STORAGE", "Atlas Data Federation"}, } for _, tc := range tests { From d6101b914138a70070efc845e8f4217d0469a18d Mon Sep 17 00:00:00 2001 From: cbullinger Date: Wed, 9 Jul 2025 09:21:43 -0400 Subject: [PATCH 10/11] Update internal readme --- .../go/atlas-sdk-go/INTERNAL_README.md | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/usage-examples/go/atlas-sdk-go/INTERNAL_README.md b/usage-examples/go/atlas-sdk-go/INTERNAL_README.md index ab4d2c3..c26729f 100644 --- a/usage-examples/go/atlas-sdk-go/INTERNAL_README.md +++ b/usage-examples/go/atlas-sdk-go/INTERNAL_README.md @@ -1,33 +1,36 @@ # Atlas SDK for Go > NOTE: This is an internal-only file and refers to the internal project details. -> The external project details are documented in the README.md file. +> User-facing project details are documented in the [README.md](README.md) file, which is copied to the artifact repo. This project demonstrates how to script specific functionality using the Atlas SDK for Go. Code examples are used in the Atlas Architecture Center docs, and -a sanitized copy of the project is available in a user-facing repo: +a sanitized copy of the project is available in a user-facing "artifact repo": https://github.com/mongodb/atlas-architecture-go-sdk. ## Project Structure ```text . -├── cmd/ # Self-contained, runnable examples by category +├── examples/ # Self-contained, runnable examples by category +│ ├── billing/ +│ └── monitoring/ ├── configs/ # Atlas details -├── internal # Shared utilities and helpers (NOTE: ANY TEST FILES ARE INTERNAL ONLY) +├── internal # Shared utilities and helpers (NOTE: ALL TEST FILES ARE INTERNAL ONLY - DON'T COPY TO ARTIFACT REPO) ├── go.mod ├── CHANGELOG.md # User-facing list of major project changes │── README.md # User-facing README for copied project │── INTERNAL_README.md # (NOTE: INTERNAL ONLY - DON'T COPY TO ARTIFACT REPO) -│── scripts/ # (NOTE: INTERNAL ONLY) snip and copy code examples +│── scripts/ # (NOTE: INTERNAL ONLY) Script to generate code examples │ └── bluehawk.sh -├── .gitignore -└── .env.example +├── .gitignore # Ignores .env file and log output +└── .env.example # Example environment variables ``` +Unless noted, all files are Bluehawk-copied to the generated `project-copy` dir, then copied to the artifact repo. This includes `.gitignore` and `.env.example`. ## Adding Examples To add examples to the project: - +[TODO] ## Runnable Scripts You can run individual scripts from the terminal. For example, to run `get_logs/main.go`: @@ -65,7 +68,7 @@ Contact the Developer Docs team with any setup questions or issues. ## Write Tests -... TODO +[TODO] ## Generate Examples From 56f18b1f1a7bfe22c2b51f1da3097c871a086f05 Mon Sep 17 00:00:00 2001 From: cbullinger Date: Wed, 9 Jul 2025 14:51:08 -0400 Subject: [PATCH 11/11] Fix config loader to correctly extract host name --- usage-examples/go/atlas-sdk-go/CHANGELOG.md | 2 +- .../go/atlas-sdk-go/INTERNAL_README.md | 9 +++++++++ .../examples/monitoring/logs/main.go | 3 +++ .../atlas-sdk-go/internal/billing/collector.go | 8 +++----- .../atlas-sdk-go/internal/config/loadconfig.go | 16 ++++++++++++++++ .../go/atlas-sdk-go/internal/fileutils/io.go | 12 +++++++++++- 6 files changed, 43 insertions(+), 7 deletions(-) diff --git a/usage-examples/go/atlas-sdk-go/CHANGELOG.md b/usage-examples/go/atlas-sdk-go/CHANGELOG.md index 1675a9b..08d81a3 100644 --- a/usage-examples/go/atlas-sdk-go/CHANGELOG.md +++ b/usage-examples/go/atlas-sdk-go/CHANGELOG.md @@ -5,7 +5,7 @@ Notable changes to the project. ## v1.1 (2025-06-17) ### Added - Example scripts for fetching cross-organization billing information. - + ## v1.0 (2025-05-29) ### Added - Initial project structure with example scripts for fetching logs and metrics. diff --git a/usage-examples/go/atlas-sdk-go/INTERNAL_README.md b/usage-examples/go/atlas-sdk-go/INTERNAL_README.md index c26729f..074dc64 100644 --- a/usage-examples/go/atlas-sdk-go/INTERNAL_README.md +++ b/usage-examples/go/atlas-sdk-go/INTERNAL_README.md @@ -8,6 +8,15 @@ SDK for Go. Code examples are used in the Atlas Architecture Center docs, and a sanitized copy of the project is available in a user-facing "artifact repo": https://github.com/mongodb/atlas-architecture-go-sdk. +## Terms + +Artifact Repo +The user-facing GitHub repo that contains the sanitized version of this project (i.e. stripped of any internal comments, markup, test files, etc.). +Files are copied + + + + ## Project Structure ```text . diff --git a/usage-examples/go/atlas-sdk-go/examples/monitoring/logs/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring/logs/main.go index 4ec061c..b24a713 100644 --- a/usage-examples/go/atlas-sdk-go/examples/monitoring/logs/main.go +++ b/usage-examples/go/atlas-sdk-go/examples/monitoring/logs/main.go @@ -42,6 +42,8 @@ func main() { HostName: cfg.HostName, LogName: "mongodb", } + fmt.Printf("Request parameters: GroupID=%s, HostName=%s, LogName=%s\n", + cfg.ProjectID, cfg.HostName, p.LogName) rc, err := logs.FetchHostLogs(ctx, client.MonitoringAndLogsApi, p) if err != nil { errors.ExitWithError("Failed to fetch logs", err) @@ -49,6 +51,7 @@ func main() { defer fileutils.SafeClose(rc) // Prepare output paths + // If the ATLAS_DOWNLOADS_DIR env variable is set, it will be used as the base directory for output files outDir := "logs" prefix := fmt.Sprintf("%s_%s", p.HostName, p.LogName) gzPath, err := fileutils.GenerateOutputPath(outDir, prefix, "gz") diff --git a/usage-examples/go/atlas-sdk-go/internal/billing/collector.go b/usage-examples/go/atlas-sdk-go/internal/billing/collector.go index c17750d..7336ad1 100644 --- a/usage-examples/go/atlas-sdk-go/internal/billing/collector.go +++ b/usage-examples/go/atlas-sdk-go/internal/billing/collector.go @@ -37,7 +37,7 @@ type ProjectInfo struct { // CollectLineItemBillingData retrieves all pending invoices for the specified organization, // transforms them into detailed billing records, and filters out items processed before lastProcessedDate. -// Returns a slice of billing Details or an error if no valid invoices or line items are found. +// Returns a slice of billing Details if pending invoices exists or an error if the operation fails. func CollectLineItemBillingData(ctx context.Context, sdk admin.InvoicesApi, orgSdk admin.OrganizationsApi, orgID string, lastProcessedDate *time.Time) ([]Detail, error) { req := sdk.ListPendingInvoices(ctx, orgID) r, _, err := req.Execute() @@ -46,11 +46,9 @@ func CollectLineItemBillingData(ctx context.Context, sdk admin.InvoicesApi, orgS return nil, errors.FormatError("list pending invoices", orgID, err) } if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { - return nil, &errors.NotFoundError{Resource: "pending invoices", ID: orgID} + return nil, nil } - fmt.Printf("Found %d pending invoice(s)\n", len(r.GetResults())) - // Get organization name orgName, err := getOrganizationName(ctx, orgSdk, orgID) if err != nil { @@ -112,7 +110,7 @@ func processInvoices(invoices []admin.BillingInvoice, orgID, orgName string, las return billingDetails, nil } -// getOrganizationName fetches organization name from API or returns orgID if not found +// getOrganizationName fetches an organization's name for the given organization ID. func getOrganizationName(ctx context.Context, sdk admin.OrganizationsApi, orgID string) (string, error) { req := sdk.GetOrganization(ctx, orgID) org, _, err := req.Execute() diff --git a/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go b/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go index 0cff7fc..c6621b9 100644 --- a/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go +++ b/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "os" + "strings" "atlas-sdk-go/internal/errors" ) @@ -37,11 +38,26 @@ func LoadConfig(path string) (*Config, error) { return nil, errors.WithContext(err, "parsing configuration file") } + if config.OrgID == "" { + return nil, &errors.ValidationError{ + Message: "organization ID is required in configuration", + } + } if config.ProjectID == "" { return nil, &errors.ValidationError{ Message: "project ID is required in configuration", } } + if config.HostName == "" { + if host, _, ok := strings.Cut(config.ProcessID, ":"); ok { + config.HostName = host + } else { + return nil, &errors.ValidationError{ + Message: "process ID must be in the format 'hostname:port'", + } + } + } + return &config, nil } diff --git a/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go index 7a97b37..3e04d27 100644 --- a/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go @@ -52,8 +52,18 @@ func SafeCopy(dst io.Writer, src io.Reader) error { // :remove-start: // SafeDelete removes files generated in the specified directory -// NOTE: INTERNAL ONLY FUNCTION; remove this and "path/filepath" import from Bluehawked files +// NOTE: INTERNAL ONLY FUNCTION; before running `bluehawk.sh`, ensure this this and "path/filepath" import are marked for removal func SafeDelete(dir string) error { + // Check for global downloads directory + defaultDir := os.Getenv("ATLAS_DOWNLOADS_DIR") + if defaultDir != "" { + dir = filepath.Join(defaultDir, dir) + } + // Check if directory exists before attempting to walk it + if _, err := os.Stat(dir); os.IsNotExist(err) { + return errors.WithContext(err, "directory does not exist") + } + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return errors.WithContext(err, "walking directory")