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..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 @@ -7,27 +7,35 @@ import ( "fmt" "log" - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" + "atlas-sdk-go/internal/errors" "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() { - _ = 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 +50,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..8d43842 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.historical-billing.go @@ -0,0 +1,100 @@ +// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk +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" + + "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..3247f42 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/main.snippet.line-items.go @@ -0,0 +1,103 @@ +// See entire project at https://github.com/mongodb/atlas-architecture-go-sdk +package main + +import ( + "context" + "fmt" + "log" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "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) + } + + 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 + 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/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/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..7414bbc --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/historical/main.go @@ -0,0 +1,99 @@ +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" + + "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..634fac2 --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/billing/line_items/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "fmt" + "log" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "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) + } + + 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 + 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/usage-examples/go/atlas-sdk-go/cmd/get_metrics_disk/main.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_disk/main.go similarity index 53% rename from usage-examples/go/atlas-sdk-go/cmd/get_metrics_disk/main.go rename to generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_disk/main.go index 2520d47..9ccedb1 100644 --- a/usage-examples/go/atlas-sdk-go/cmd/get_metrics_disk/main.go +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_disk/main.go @@ -1,7 +1,3 @@ -// :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 ( @@ -10,27 +6,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, @@ -39,14 +41,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)) } -// :snippet-end: [get-metrics-dev] 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/examples/monitoring/metrics_process/main.go similarity index 60% rename from generated-usage-examples/go/atlas-sdk-go/project-copy/cmd/get_metrics_process/main.go rename to generated-usage-examples/go/atlas-sdk-go/project-copy/examples/monitoring/metrics_process/main.go index 26fb4a3..fe74e79 100644 --- 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/examples/monitoring/metrics_process/main.go @@ -6,27 +6,35 @@ import ( "fmt" "log" - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" + "atlas-sdk-go/internal/errors" "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() { - _ = 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, @@ -41,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/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/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/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/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/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..57e39ff --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/billing/sku_classifier.go @@ -0,0 +1,78 @@ +package billing + +import ( + "strings" +) + +// determineProvider identifies the cloud provider based on SKU +func determineProvider(sku string) string { + uppercaseSku := strings.ToUpper(sku) + + if strings.Contains(uppercaseSku, "AWS") { + return "AWS" + } else if strings.Contains(strings.ToUpper(sku), "AZURE") { + return "AZURE" + } else if strings.Contains(strings.ToUpper(sku), "GCP") { + return "GCP" + } + return "n/a" +} + +// determineInstance extracts the instance type from SKU +func determineInstance(sku string) string { + uppercaseSku := strings.ToUpper(sku) + + parts := strings.Split(uppercaseSku, "_INSTANCE_") + if len(parts) > 1 { + return parts[1] + } + return "non-instance" +} + +// determineCategory identifies the service category based on SKU +func determineCategory(sku string) string { + 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 _, pc := range categoryPatterns { + if strings.Index(uppercaseSku, pc.pattern) != -1 { + return pc.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..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 @@ -2,11 +2,9 @@ package config import ( "encoding/json" - "fmt" "os" - "strings" - "atlas-sdk-go/internal" + "atlas-sdk-go/internal/errors" ) type Config struct { @@ -18,31 +16,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..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 ( - "fmt" "os" "strings" + + "atlas-sdk-go/internal/errors" ) const ( @@ -32,7 +33,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/logs/gzip.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/compress.go similarity index 73% rename from generated-usage-examples/go/atlas-sdk-go/project-copy/internal/logs/gzip.go rename to generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/compress.go index 13901f6..464e260 100644 --- 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/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/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..542f0ad --- /dev/null +++ b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/fileutils/io.go @@ -0,0 +1,50 @@ +package fileutils + +import ( + "io" + "log" + "os" + + "atlas-sdk-go/internal/errors" +) + +// 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..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 @@ -4,17 +4,23 @@ import ( "context" "io" - "atlas-sdk-go/internal" - "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/errors" ) -// 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/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/metrics/disk.go b/generated-usage-examples/go/atlas-sdk-go/project-copy/internal/metrics/disk.go index 2d8900c..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 @@ -2,25 +2,25 @@ package metrics import ( "context" - "fmt" - "atlas-sdk-go/internal" + "atlas-sdk-go/internal/errors" "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..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 @@ -2,24 +2,24 @@ package metrics import ( "context" - "fmt" - - "atlas-sdk-go/internal" "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/errors" ) -// 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 } 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/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 ab4d2c3..074dc64 100644 --- a/usage-examples/go/atlas-sdk-go/INTERNAL_README.md +++ b/usage-examples/go/atlas-sdk-go/INTERNAL_README.md @@ -1,33 +1,45 @@ # 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. +## 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 . -├── 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 +77,7 @@ Contact the Developer Docs team with any setup questions or issues. ## Write Tests -... TODO +[TODO] ## Generate Examples 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/cmd/get_linked_orgs/main.go b/usage-examples/go/atlas-sdk-go/cmd/get_linked_orgs/main.go deleted file mode 100644 index fe8b0f7..0000000 --- a/usage-examples/go/atlas-sdk-go/cmd/get_linked_orgs/main.go +++ /dev/null @@ -1,62 +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" - - "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) - } -} - -// :snippet-end: [get-linked-orgs] diff --git a/usage-examples/go/atlas-sdk-go/cmd/get_logs/main.go b/usage-examples/go/atlas-sdk-go/cmd/get_logs/main.go deleted file mode 100644 index a90ced0..0000000 --- a/usage-examples/go/atlas-sdk-go/cmd/get_logs/main.go +++ /dev/null @@ -1,73 +0,0 @@ -// :snippet-start: get-logs -// :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" - "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) - // :remove-start: - // NOTE: Internal-only function to clean up any downloaded files - if err := internal.SafeDelete(outDir); err != nil { - log.Printf("Cleanup error: %v", err) - } - fmt.Println("Deleted generated files from", outDir) - // :remove-end: -} - -// :snippet-end: [get-logs] 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..9f07f5e --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/examples/billing/historical/main.go @@ -0,0 +1,120 @@ +// :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 ( + "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" + + "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) + // :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 { + 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) +} + +// :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 new file mode 100644 index 0000000..5a96db1 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/examples/billing/line_items/main.go @@ -0,0 +1,124 @@ +// :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 ( + "context" + "fmt" + "log" + + "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "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) + } + + 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 + outDir := "invoices" + 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: +} + +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) +} + +// :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 new file mode 100644 index 0000000..316c8d3 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/examples/billing/linked_orgs/main.go @@ -0,0 +1,82 @@ +// :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 ( + "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) + } +} + +// :snippet-end: [linked-billing] +// :state-remove-start: copy +// NOTE: INTERNAL +// ** 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/examples/monitoring/logs/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring/logs/main.go new file mode 100644 index 0000000..b24a713 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/examples/monitoring/logs/main.go @@ -0,0 +1,86 @@ +// :snippet-start: get-logs +// :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/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", + } + 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) + } + 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") + 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) + // :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: +} + +// :snippet-end: [get-logs] 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..74fb9a8 --- /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/cmd/get_metrics_process/main.go b/usage-examples/go/atlas-sdk-go/examples/monitoring/metrics_process/main.go similarity index 65% 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..880d137 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 @@ -10,27 +10,35 @@ import ( "fmt" "log" - "github.com/joho/godotenv" - "go.mongodb.org/atlas-sdk/v20250219001/admin" + "atlas-sdk-go/internal/errors" "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() { - _ = 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, @@ -45,12 +53,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/usage-examples/go/atlas-sdk-go/internal/auth/client.go b/usage-examples/go/atlas-sdk-go/internal/auth/client.go index 7e8f192..a0b81ce 100644 --- a/usage-examples/go/atlas-sdk-go/internal/auth/client.go +++ b/usage-examples/go/atlas-sdk-go/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/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..d7adf44 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/auth/client_test.go @@ -0,0 +1,56 @@ +package auth_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "atlas-sdk-go/internal/auth" + "atlas-sdk-go/internal/config" + internalerrors "atlas-sdk-go/internal/errors" +) + +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 new file mode 100644 index 0000000..7336ad1 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/billing/collector.go @@ -0,0 +1,132 @@ +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 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() + + if err != nil { + return nil, errors.FormatError("list pending invoices", orgID, err) + } + if r == nil || !r.HasResults() || len(r.GetResults()) == 0 { + return nil, nil + } + + // 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 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() + 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/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/crossorg.go b/usage-examples/go/atlas-sdk-go/internal/billing/crossorg.go deleted file mode 100644 index e68edbd..0000000 --- a/usage-examples/go/atlas-sdk-go/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/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..1b0fb2d --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/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/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 new file mode 100644 index 0000000..2c12337 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/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/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 deleted file mode 100644 index 7d35a83..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" -) - -// 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/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 b6b0907..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.GetLinkedOrgs(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.GetLinkedOrgs(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.GetLinkedOrgs(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.GetLinkedOrgs(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.go b/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier.go new file mode 100644 index 0000000..57e39ff --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier.go @@ -0,0 +1,78 @@ +package billing + +import ( + "strings" +) + +// determineProvider identifies the cloud provider based on SKU +func determineProvider(sku string) string { + uppercaseSku := strings.ToUpper(sku) + + if strings.Contains(uppercaseSku, "AWS") { + return "AWS" + } else if strings.Contains(strings.ToUpper(sku), "AZURE") { + return "AZURE" + } else if strings.Contains(strings.ToUpper(sku), "GCP") { + return "GCP" + } + return "n/a" +} + +// determineInstance extracts the instance type from SKU +func determineInstance(sku string) string { + uppercaseSku := strings.ToUpper(sku) + + parts := strings.Split(uppercaseSku, "_INSTANCE_") + if len(parts) > 1 { + return parts[1] + } + return "non-instance" +} + +// determineCategory identifies the service category based on SKU +func determineCategory(sku string) string { + 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 _, pc := range categoryPatterns { + if strings.Index(uppercaseSku, pc.pattern) != -1 { + return pc.category + } + } + 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 new file mode 100644 index 0000000..5c4ba4b --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/billing/sku_classifier_test.go @@ -0,0 +1,91 @@ +package billing + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetermineProvider(t *testing.T) { + tests := []struct { + name string + sku string + expected string + }{ + {"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 { + 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", "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"}, + {"Search instance", "NDS_AWS_SEARCH_INSTANCE_S20_COMPUTE_NVME", "S20_COMPUTE_NVME"}, + } + + 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", "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 { + 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/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..c6621b9 100644 --- a/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go +++ b/usage-examples/go/atlas-sdk-go/internal/config/loadconfig.go @@ -2,11 +2,10 @@ package config import ( "encoding/json" - "fmt" "os" "strings" - "atlas-sdk-go/internal" + "atlas-sdk-go/internal/errors" ) type Config struct { @@ -18,31 +17,47 @@ 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 path == "" { + return nil, &errors.ValidationError{ + Message: "configuration file path cannot be empty", + } + } + + data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("open config %s: %w", path, err) + if os.IsNotExist(err) { + return nil, &errors.NotFoundError{Resource: "configuration file", ID: path} + } + return nil, errors.WithContext(err, "reading configuration file") } - 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) + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, errors.WithContext(err, "parsing configuration file") } - if c.BaseURL == "" { - c.BaseURL = "https://cloud.mongodb.com" + if config.OrgID == "" { + return nil, &errors.ValidationError{ + Message: "organization ID is required in configuration", + } } - if c.HostName == "" { - // Go 1.18+: - if host, _, ok := strings.Cut(c.ProcessID, ":"); ok { - c.HostName = host + if config.ProjectID == "" { + return nil, &errors.ValidationError{ + Message: "project ID is required in configuration", } } - if c.OrgID == "" || c.ProjectID == "" { - return nil, fmt.Errorf("ATLAS_ORG_ID and ATLAS_PROJECT_ID are required") + 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 &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..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 ( - "fmt" "os" "strings" + + "atlas-sdk-go/internal/errors" ) const ( @@ -32,7 +33,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 new file mode 100644 index 0000000..ca5fd5b --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/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/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..e2ce586 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/data/export/formats_test.go @@ -0,0 +1,166 @@ +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) { + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "test.json") + testData := TestStruct{ID: 1, Name: "Test", Value: 99.99} + + err := ToJSON(testData, filePath) + require.NoError(t, err) + + 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) { + tempDir := t.TempDir() + dirPath := filepath.Join(tempDir, "subdir1", "subdir2") + filePath := filepath.Join(dirPath, "test.json") + testData := TestStruct{ID: 1, Name: "Test", Value: 99.99} + + err := ToJSON(testData, filePath) + require.NoError(t, err) + + dirInfo, err := os.Stat(dirPath) + require.NoError(t, err) + assert.True(t, dirInfo.IsDir(), "Expected directory to be created") + }) +} + +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"}, + } + + err := ToCSV(testData, filePath) + require.NoError(t, err) + + 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, "Expected CSV rows to match input data") + }) + + 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), + } + } + + err := ToCSVWithMapper(testData, filePath, headers, rowMapper) + require.NoError(t, err) + + 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 new file mode 100644 index 0000000..ca8130b --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/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/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/fileutils/io.go b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go new file mode 100644 index 0000000..3e04d27 --- /dev/null +++ b/usage-examples/go/atlas-sdk-go/internal/fileutils/io.go @@ -0,0 +1,85 @@ +package fileutils + +import ( + "io" + "log" + "os" + "path/filepath" // :remove: + + "atlas-sdk-go/internal/errors" +) + +// 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 +} + +// :remove-start: + +// SafeDelete removes files generated in the specified directory +// 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") + } + if !info.IsDir() { + if removeErr := os.Remove(path); removeErr != nil { + log.Printf("warning: failed to delete file %s: %v", path, removeErr) + } + } + return nil + }) + + if err != nil { + return errors.WithContext(err, "cleaning up directory") + } + return nil +} + +// :remove-end: 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/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/logs/fetch.go b/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go index b525983..a975af0 100644 --- a/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go +++ b/usage-examples/go/atlas-sdk-go/internal/logs/fetch.go @@ -4,17 +4,23 @@ import ( "context" "io" - "atlas-sdk-go/internal" - "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/errors" ) -// 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/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..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 @@ -2,13 +2,17 @@ package logs import ( "context" + "errors" "fmt" "io" "strings" "testing" - "atlas-sdk-go/internal" + internalerrors "atlas-sdk-go/internal/errors" + "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" @@ -27,10 +31,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", @@ -57,6 +62,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 { @@ -67,14 +85,25 @@ 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 } 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..15d6524 100644 --- a/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go +++ b/usage-examples/go/atlas-sdk-go/internal/metrics/disk.go @@ -2,25 +2,25 @@ package metrics import ( "context" - "fmt" - "atlas-sdk-go/internal" + "atlas-sdk-go/internal/errors" "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/usage-examples/go/atlas-sdk-go/internal/metrics/process.go b/usage-examples/go/atlas-sdk-go/internal/metrics/process.go index feda72a..3a801b8 100644 --- a/usage-examples/go/atlas-sdk-go/internal/metrics/process.go +++ b/usage-examples/go/atlas-sdk-go/internal/metrics/process.go @@ -2,24 +2,24 @@ package metrics import ( "context" - "fmt" - - "atlas-sdk-go/internal" "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/errors" ) -// 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 } diff --git a/usage-examples/go/atlas-sdk-go/internal/utils.go b/usage-examples/go/atlas-sdk-go/internal/utils.go deleted file mode 100644 index 99b4225..0000000 --- a/usage-examples/go/atlas-sdk-go/internal/utils.go +++ /dev/null @@ -1,61 +0,0 @@ -package internal - -import ( - "fmt" - "io" - "log" - "os" // :remove: - "path/filepath" // :remove: - - "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 -} - -// :remove-start: - -// 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 - } - if !info.IsDir() { - if removeErr := os.Remove(path); removeErr != nil { - log.Printf("warning: failed to delete file %s: %v", path, removeErr) - } - } - return nil - }) - if err != nil { - return err - } - return nil -} - -// :remove-end: 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")