From 6b60215d6e70a686eaaeeada0374d9aac1379c2f Mon Sep 17 00:00:00 2001 From: cbullinger Date: Mon, 7 Jul 2025 14:57:26 -0400 Subject: [PATCH] Add billing scripts --- .env.example | 2 +- CHANGELOG.md | 6 +- LICENSE.md | 366 +++++++++--------- README.md | 116 +++--- cmd/get_logs/main.go | 61 --- examples/billing/historical/main.go | 99 +++++ examples/billing/line_items/main.go | 102 +++++ examples/billing/linked_orgs/main.go | 65 ++++ examples/monitoring/logs/main.go | 71 ++++ .../monitoring/metrics_disk}/main.go | 31 +- .../monitoring/metrics_process}/main.go | 30 +- go.mod | 4 - internal/auth/client.go | 16 +- internal/billing/collector.go | 134 +++++++ internal/billing/invoices.go | 132 +++++++ internal/billing/linked_billing.go | 48 +++ internal/billing/sku_classifier.go | 53 +++ internal/config/loadall.go | 6 +- internal/config/loadconfig.go | 41 +- internal/config/loadenv.go | 7 +- internal/data/export/formats.go | 118 ++++++ internal/errors/utils.go | 48 +++ .../{logs/gzip.go => fileutils/compress.go} | 12 +- internal/fileutils/io.go | 50 +++ internal/fileutils/path.go | 40 ++ internal/logs/fetch.go | 22 +- internal/logs/file.go | 24 -- internal/metrics/disk.go | 17 +- internal/metrics/process.go | 15 +- internal/utils.go | 24 -- 30 files changed, 1327 insertions(+), 433 deletions(-) delete mode 100644 cmd/get_logs/main.go create mode 100644 examples/billing/historical/main.go create mode 100644 examples/billing/line_items/main.go create mode 100644 examples/billing/linked_orgs/main.go create mode 100644 examples/monitoring/logs/main.go rename {cmd/get_metrics_disk => examples/monitoring/metrics_disk}/main.go (53%) rename {cmd/get_metrics_process => examples/monitoring/metrics_process}/main.go (60%) create mode 100644 internal/billing/collector.go create mode 100644 internal/billing/invoices.go create mode 100644 internal/billing/linked_billing.go create mode 100644 internal/billing/sku_classifier.go create mode 100644 internal/data/export/formats.go create mode 100644 internal/errors/utils.go rename internal/{logs/gzip.go => fileutils/compress.go} (73%) create mode 100644 internal/fileutils/io.go create mode 100644 internal/fileutils/path.go delete mode 100644 internal/logs/file.go delete mode 100644 internal/utils.go diff --git a/.env.example b/.env.example index 7673851..a1f8da0 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,2 @@ MONGODB_ATLAS_SERVICE_ACCOUNT_ID=your_mdb_service_account_id -MONGODB_ATLAS_SERVICE_ACCOUNT_SECRET=your_mdb_service_account_secret \ No newline at end of file +MONGODB_ATLAS_SERVICE_ACCOUNT_SECRET=your_mdb_service_account_secret diff --git a/CHANGELOG.md b/CHANGELOG.md index 0046604..1675a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ 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. \ No newline at end of file +- Initial project structure with example scripts for fetching logs and metrics. diff --git a/LICENSE.md b/LICENSE.md index 261eeb9..c319da3 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -2,180 +2,180 @@ Version 2.0, January 2004 http://www.apache.org/licenses/ - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" @@ -186,16 +186,16 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] +Copyright [yyyy] [name of copyright owner] - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 995b4b9..b17f5cb 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,48 @@ # Atlas Go SDK Examples for the MongoDB Atlas Architecture Center -This repository contains runnable examples for the -[Atlas Go SDK](https://www.mongodb.com/docs/atlas/sdk/) -that align with best practices from the MongoDB -[Atlas Architecture -Center](https://www.mongodb.com/docs/atlas/architecture/current/). +This repository contains runnable examples for the +[Atlas Go SDK](https://www.mongodb.com/docs/atlas/sdk/) +that align with best practices from the MongoDB +[Atlas Architecture Center](https://www.mongodb.com/docs/atlas/architecture/current/). -Use these examples as starting points for your own Atlas integration. +Use these examples as starting points for your own Atlas integration. ## Features -Currently, the repository includes examples for: +Currently, the repository includes examples that demonstrate the following: - Authenticate with service accounts -- Retrieve cluster and database metrics +- Return cluster and database metrics - Download logs for a specific host -- Programmatically manage Atlas resources +- 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 and improvements to existing code. +As the Architecture Center documentation evolves, this repository will be updated with new examples +and improvements to existing code. ## Project Structure ```text . -├── cmd # Runnable examples by category -│ ├── 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 ├── .gitignore # Ignores .env file and log output └── .env.example # Example environment variables ``` @@ -46,24 +52,20 @@ As the Architecture Center documentation evolves, this repository will be update - Go 1.16 or later - A MongoDB Atlas project and cluster - Service account credentials with appropriate permissions. See - [Service Account Overview](https://www.mongodb.com/docs/atlas/api/service-accounts-overview/). + [Service Account Overview](https://www.mongodb.com/docs/atlas/api/service-accounts-overview/). -## Setup +## Setting Environment Variables -1. Clone the repository: - ```bash - git clone https://github.com/mongodb/atlas-architecture-go-sdk.git - cd atlas-architecture-go-sdk - ``` - -2. Create a `.env` file in the root directory with your MongoDB Atlas service account credentials: - ```env +1. Create a `.env` file in the root directory with your MongoDB Atlas service account credentials: + ```dotenv MONGODB_ATLAS_SERVICE_ACCOUNT_ID=your_service_account_id MONGODB_ATLAS_SERVICE_ACCOUNT_SECRET=your_service_account_secret ``` - > **NOTE:** For production, use a secrets manager (e.g. HashiCorp Vault, AWS Secrets Manager) instead of environment variables. See [Secrets management](https://www.mongodb.com/docs/atlas/architecture/current/auth/#secrets-management). + > **NOTE:** For production, use a secrets manager (e.g. HashiCorp Vault, AWS Secrets Manager) + > instead of environment variables. + > See [Secrets management](https://www.mongodb.com/docs/atlas/architecture/current/auth/#secrets-management). -3. Configure Atlas details in `configs/config.json`: +2. Configure Atlas details in `configs/config.json`: ```json { "MONGODB_ATLAS_BASE_URL": "", @@ -75,46 +77,60 @@ As the Architecture Center documentation evolves, this repository will be update ``` > **NOTE:** The base URL defaults to `https://cloud.mongodb.com` if not specified. -4. Install dependencies: - ```bash - go mod tidy - ``` - ## Running Examples -### Fetch Logs +Examples in this project are intended to be run as individual scripts. +You can also adjust them to suit your needs: + +- Modify time ranges +- Add filtering parameters +- Change output formats + +### Billing +#### Get Historical Invoices ```bash -go run cmd/get_logs/main.go +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 examples/billing/linked_orgs/main.go +``` + +### Logs Logs output to `./logs` as `.gz` and `.txt`. -### Fetch Metrics +#### Fetch All Host Logs ```bash -go run cmd/get_metrics_disk/main.go -# or -go run cmd/get_metrics_process/main.go +go run examples/monitoring/logs/main.go ``` -Metrics print to the console. -## Customization +### Metrics +Metrics print to the console. -Adjust the examples to suit your needs: +#### Get Disk Measurements +```bash +go run examples/monitoring/metrics_disk/main.go +``` -- Modify time ranges -- Add filtering parameters -- Change output formats -- Integrate data into your own tooling +#### Get Cluster Metrics +```bash +go run examples/monitoring/metrics_process/main.go +``` ## Changelog -For list of changes to this project, see [CHANGELOG](CHANGELOG.md). +For list of major changes to this project, see [CHANGELOG](CHANGELOG.md). ## Reporting Issues -Use the "Rate this page" widget on the -[Atlas Architecture Center](https://www.mongodb.com/docs/atlas/architecture/current/) +Use the "Rate this page" widget on the +[Atlas Architecture Center](https://www.mongodb.com/docs/atlas/architecture/current/) docs to leave feedback or file issues. ## License -This project is licensed under Apache 2.0. See [LICENSE](LICENSE). \ No newline at end of file +This project is licensed under Apache 2.0. See [LICENSE](LICENSE.md). diff --git a/cmd/get_logs/main.go b/cmd/get_logs/main.go deleted file mode 100644 index 99ec158..0000000 --- a/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/examples/billing/historical/main.go b/examples/billing/historical/main.go new file mode 100644 index 0000000..7414bbc --- /dev/null +++ b/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/examples/billing/line_items/main.go b/examples/billing/line_items/main.go new file mode 100644 index 0000000..634fac2 --- /dev/null +++ b/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/examples/billing/linked_orgs/main.go b/examples/billing/linked_orgs/main.go new file mode 100644 index 0000000..4fd48c8 --- /dev/null +++ b/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/examples/monitoring/logs/main.go b/examples/monitoring/logs/main.go new file mode 100644 index 0000000..fb29556 --- /dev/null +++ b/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/cmd/get_metrics_disk/main.go b/examples/monitoring/metrics_disk/main.go similarity index 53% rename from cmd/get_metrics_disk/main.go rename to examples/monitoring/metrics_disk/main.go index 912cc75..9ccedb1 100644 --- a/cmd/get_metrics_disk/main.go +++ b/examples/monitoring/metrics_disk/main.go @@ -6,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, @@ -35,13 +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)) } diff --git a/cmd/get_metrics_process/main.go b/examples/monitoring/metrics_process/main.go similarity index 60% rename from cmd/get_metrics_process/main.go rename to examples/monitoring/metrics_process/main.go index 26fb4a3..fe74e79 100644 --- a/cmd/get_metrics_process/main.go +++ b/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/go.mod b/go.mod index 29ef1b0..6b94c9f 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,14 @@ module atlas-sdk-go go 1.24 - require ( github.com/joho/godotenv v1.5.1 - github.com/stretchr/testify v1.10.0 go.mongodb.org/atlas-sdk/v20250219001 v20250219001.1.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/mongodb-forks/digest v1.1.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect golang.org/x/oauth2 v0.30.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/auth/client.go b/internal/auth/client.go index 7e8f192..a0b81ce 100644 --- a/internal/auth/client.go +++ b/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/internal/billing/collector.go b/internal/billing/collector.go new file mode 100644 index 0000000..c17750d --- /dev/null +++ b/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/internal/billing/invoices.go b/internal/billing/invoices.go new file mode 100644 index 0000000..1b0fb2d --- /dev/null +++ b/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/internal/billing/linked_billing.go b/internal/billing/linked_billing.go new file mode 100644 index 0000000..2c12337 --- /dev/null +++ b/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/internal/billing/sku_classifier.go b/internal/billing/sku_classifier.go new file mode 100644 index 0000000..c65dc1a --- /dev/null +++ b/internal/billing/sku_classifier.go @@ -0,0 +1,53 @@ +package billing + +import ( + "strings" +) + +// determineProvider identifies the cloud provider based on SKU +func determineProvider(sku string) string { + if strings.Contains(sku, "AWS") { + return "AWS" + } else if strings.Contains(sku, "AZURE") { + return "AZURE" + } else if strings.Contains(sku, "GCP") { + return "GCP" + } + return "n/a" +} + +// determineInstance extracts the instance type from SKU +func determineInstance(sku string) string { + parts := strings.Split(sku, "_INSTANCE_") + if len(parts) > 1 { + return parts[1] + } + return "non-instance" +} + +// determineCategory categorizes the SKU +func determineCategory(sku string) string { + categoryPatterns := map[string]string{ + "_INSTANCE": "instances", + "BACKUP": "backup", + "PIT_RESTORE": "backup", + "DATA_TRANSFER": "data xfer", + "STORAGE": "storage", + "BI_CONNECTOR": "bi-connector", + "DATA_LAKE": "data lake", + "AUDITING": "audit", + "ATLAS_SUPPORT": "support", + "FREE_SUPPORT": "free support", + "CHARTS": "charts", + "SERVERLESS": "serverless", + "SECURITY": "security", + "PRIVATE_ENDPOINT": "private endpoint", + } + + for pattern, category := range categoryPatterns { + if strings.Contains(sku, pattern) { + return category + } + } + return "other" +} diff --git a/internal/config/loadall.go b/internal/config/loadall.go index 6cad472..d6b3110 100644 --- a/internal/config/loadall.go +++ b/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/internal/config/loadconfig.go b/internal/config/loadconfig.go index e55cd57..0cff7fc 100644 --- a/internal/config/loadconfig.go +++ b/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/internal/config/loadenv.go b/internal/config/loadenv.go index 16dbeb2..2d31065 100644 --- a/internal/config/loadenv.go +++ b/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/internal/data/export/formats.go b/internal/data/export/formats.go new file mode 100644 index 0000000..ca5fd5b --- /dev/null +++ b/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/internal/errors/utils.go b/internal/errors/utils.go new file mode 100644 index 0000000..ca8130b --- /dev/null +++ b/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/internal/logs/gzip.go b/internal/fileutils/compress.go similarity index 73% rename from internal/logs/gzip.go rename to internal/fileutils/compress.go index 13901f6..464e260 100644 --- a/internal/logs/gzip.go +++ b/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/internal/fileutils/io.go b/internal/fileutils/io.go new file mode 100644 index 0000000..542f0ad --- /dev/null +++ b/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/internal/fileutils/path.go b/internal/fileutils/path.go new file mode 100644 index 0000000..3e3e6a8 --- /dev/null +++ b/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/internal/logs/fetch.go b/internal/logs/fetch.go index 48ca8bc..a975af0 100644 --- a/internal/logs/fetch.go +++ b/internal/logs/fetch.go @@ -2,25 +2,25 @@ package logs import ( "context" - "fmt" "io" "go.mongodb.org/atlas-sdk/v20250219001/admin" + + "atlas-sdk-go/internal/errors" ) -// FetchHostLogs calls the Atlas SDK and returns the raw, compressed log stream. -func FetchHostLogs( - ctx context.Context, - sdk admin.MonitoringAndLogsApi, - p *admin.GetHostLogsApiParams, -) (io.ReadCloser, error) { +// 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 { - if apiErr, ok := admin.AsError(err); ok { - return nil, fmt.Errorf("failed to fetch logs: %w – %s", err, apiErr.GetDetail()) - } - return nil, fmt.Errorf("failed to fetch logs: %w", 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/internal/logs/file.go b/internal/logs/file.go deleted file mode 100644 index b68f17f..0000000 --- a/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/internal/metrics/disk.go b/internal/metrics/disk.go index e5b1636..15d6524 100644 --- a/internal/metrics/disk.go +++ b/internal/metrics/disk.go @@ -2,26 +2,25 @@ package metrics import ( "context" - "fmt" + + "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 { - if apiErr, ok := admin.AsError(err); ok { - return nil, fmt.Errorf("fetch disk metrics: %w – %s", err, apiErr.GetDetail()) - } - return nil, fmt.Errorf("fetch disk metrics: %w", err) + return nil, errors.FormatError("fetch disk metrics", p.ProcessId+"/"+p.PartitionName, err) } - if r == nil || !r.HasMeasurements() { - return nil, fmt.Errorf("no metrics for partition %q on process %q", - p.PartitionName, p.ProcessId) + + if r == nil || !r.HasMeasurements() || len(r.GetMeasurements()) == 0 { + return nil, &errors.NotFoundError{Resource: "disk metrics", ID: p.ProcessId + "/" + p.PartitionName} } return r, nil } diff --git a/internal/metrics/process.go b/internal/metrics/process.go index c250f72..3a801b8 100644 --- a/internal/metrics/process.go +++ b/internal/metrics/process.go @@ -2,25 +2,24 @@ package metrics import ( "context" - "fmt" "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 { - if apiErr, ok := admin.AsError(err); ok { - return nil, fmt.Errorf("failed to fetch process metrics: %w – %s", err, apiErr.GetDetail()) - } - return nil, fmt.Errorf("failed to fetch process metrics: %w", err) + return nil, errors.FormatError("fetch process metrics", p.ProcessId, err) } - if r == nil || !r.HasMeasurements() { - return nil, fmt.Errorf("no metrics for process %q", p.ProcessId) + if r == nil || !r.HasMeasurements() || len(r.GetMeasurements()) == 0 { + return nil, &errors.NotFoundError{Resource: "process metrics", ID: p.ProcessId} } return r, nil } diff --git a/internal/utils.go b/internal/utils.go deleted file mode 100644 index 2491261..0000000 --- a/internal/utils.go +++ /dev/null @@ -1,24 +0,0 @@ -package internal - -import ( - "io" - "log" -) - -// 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 source to destination, 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 -}