diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5cfe43c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: "Bug report" +about: "Create a report to help us improve." +title: "" +labels: "bug, needs triage" +assignees: "" +--- + +**Describe the bug** +A clear and concise description of the bug. + +**Environment and versions** +A clear and precise description of your setup. + +- Version of the client in use. +- Services, libraries, languages and tools list and versions. + +**To reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '...' +3. Scroll down to '...' +4. See the error. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..6723a06 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Cloudcraft support + url: https://app.cloudcraft.co/app/support + about: Our friendly, knowledgeable support engineers are here to help. + - name: Cloudcraft security + url: https://www.cloudcraft.co/security + about: We care about security. If you have any questions, or encounter any issues, please contact us. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..92fef50 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: "Feature request" +about: "Suggest an idea for this project." +title: "" +labels: "enhancement, needs triage" +assignees: "" +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e4551e7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,35 @@ + + +### What does this PR do? + + + +### Additional Notes + + + +### Review checklist + +Please check relevant items below: + +- [ ] The title & description contain a short meaningful summary of work completed. +- [ ] Tests have been updated/created and are passing locally. +- [ ] I've reviewed the [CONTRIBUTING.md](/CONTRIBUTING.md) file. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5be42aa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +--- +name: 'Tests' +on: + push: + paths-ignore: + - '.editorconfig' + - '.gitignore' + - '.golangci.toml' + - 'CONTRIBUTING.md' + - 'LICENSE-3rdparty.csv' + - 'LICENSE.md' + - 'NOTICE' + - 'README.md' + - 'SUPPORT.md' + pull_request: + paths-ignore: + - '.editorconfig' + - '.gitignore' + - '.golangci.toml' + - 'CONTRIBUTING.md' + - 'LICENSE-3rdparty.csv' + - 'LICENSE.md' + - 'NOTICE' + - 'README.md' + - 'SUPPORT.md' + +jobs: + test: + runs-on: 'ubuntu-latest' + name: 'Tests' + steps: + - uses: 'actions/checkout@v4' + + - name: 'Setup Go environment' + uses: 'actions/setup-go@v4' + with: + go-version: '>=1.21.5' + + - name: 'Run mock tests' + run: 'make test' diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..c70fbad --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,49 @@ +--- +name: 'Lint' +on: + push: + paths-ignore: + - '.editorconfig' + - '.gitignore' + - '.golangci.toml' + - 'CONTRIBUTING.md' + - 'LICENSE-3rdparty.csv' + - 'LICENSE.md' + - 'NOTICE' + - 'README.md' + - 'SUPPORT.md' + pull_request: + paths-ignore: + - '.editorconfig' + - '.gitignore' + - '.golangci.toml' + - 'CONTRIBUTING.md' + - 'LICENSE-3rdparty.csv' + - 'LICENSE.md' + - 'NOTICE' + - 'README.md' + - 'SUPPORT.md' + +permissions: + contents: 'read' + +jobs: + go: + name: 'Lint Go files' + runs-on: 'ubuntu-latest' + steps: + - uses: 'actions/checkout@v4' + + - name: 'Setup Go environment' + uses: 'actions/setup-go@v4' + with: + go-version: '>=1.21.5' + + - name: 'Run govulncheck' + run: 'make vulnerabilities' + + - name: 'Run gofumpt' + run: 'make fmt' + + - name: 'Run golangci-lint' + run: 'make lint' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..85347ed --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +# Contribution guidelines + +Thank you for your interest in improving our Go client! Follow these guidelines to contribute effectively and get your patches accepted. + +## Commit sign-off + +Remember to sign-off on your commits by running `git commit --signoff` before pushing. To understand what this means, read [the Linux Kernel Developer's Certificate of Origin](https://www.kernel.org/doc/html/latest/process/submitting-patches.html#sign-your-work-the-developer-s-certificate-of-origin). + +## Submission guidelines + +Adhere to the following rules when submitting your PR: + +- **Keep it small**: Avoid changing too many things at once. +- **Individual PRs**: One PR per issue, please. +- **Commit messages**: Take a moment to write meaningful commit messages. +- **Quality assurance**: [Review your spelling and grammar](https://languagetool.org/). +- **Testing**: Write tests and ensure your changes do not break any existing functionality by running `make test`. +- **Respect the coding style**: Maintain the style of the codebase in your contributions. + +## License + +All contributions are made under [the Apache-2.0 license](LICENSE.md). diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv new file mode 100644 index 0000000..0ae3f1b --- /dev/null +++ b/LICENSE-3rdparty.csv @@ -0,0 +1,2 @@ +Component,Origin,License,Copyright +go.sum,golang.org/x/time,BSD-3-Clause,2019 The Go Authors diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4c17799 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +.POSIX: +.SUFFIXES: + +GO = go +GIT = git +RM = rm + +-include .env +export + +all: pre-commit + +pre-commit: tidy fmt lint vulnerabilities test build clean # Runs all pre-commit checks. + +commit: pre-commit # Commits the changes to the repository. + $(GIT) commit -s + +push: commit # Pushes the changes to the repository. + $(GIT) push origin trunk + +doc: # Serve the documentation locally. + $(GO) run golang.org/x/tools/cmd/godoc@latest -http=localhost:1967 + +tidy: # Updates the go.mod file to use the latest versions of all direct and indirect dependencies. + $(GO) mod tidy + +fmt: # Formats Go source files in this repository. + $(GO) run mvdan.cc/gofumpt@latest -e -extra -w . + +lint: # Runs golangci-lint using the config at the root of the repository. + $(GO) run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run ./... + +vulnerabilities: # Analyzes the codebase and looks for vulnerabilities affecting it. + $(GO) run golang.org/x/vuln/cmd/govulncheck@latest ./... + +test: # Runs unit tests. + $(GO) test -short -cover -race -vet all -mod readonly ./... + +test/integration: # Runs integration tests. + $(GO) test -cover -race -vet all -mod readonly ./tests/integration + +test/coverage: # Generates a coverage profile and open it in a browser. + $(GO) test -short -coverprofile cover.out ./... + $(GO) tool cover -html=cover.out + +clean: # Cleans cache files from tests and deletes any build output. + $(RM) -f cover.out + +.PHONY: all pre-commit commit push doc tidy fmt lint vulnerabilities test test/coverage clean diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..c2f71a4 --- /dev/null +++ b/NOTICE @@ -0,0 +1,3 @@ +Copyright 2023-Present Datadog, Inc. + +This product includes software developed at Datadog ( diff --git a/README.md b/README.md new file mode 100644 index 0000000..e575df5 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# cloudcraft-go + +[![Go Documentation](http://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://pkg.go.dev/github.com/DataDog/cloudcraft-go) +[![Go Report Card](https://goreportcard.com/badge/github.com/DataDog/cloudcraft-go)](https://goreportcard.com/report/github.com/DataDog/cloudcraft-go) + +![Cloudcraft diagram](https://static.cloudcraft.co/sdk/cloudcraft-sdk-example-1.svg) + +Visualize your cloud architecture with Cloudcraft by Datadog, [the best way to create smart AWS and Azure diagrams](https://www.cloudcraft.co/). + +Cloudcraft supports both manual and programmatic diagramming, as well as automatic reverse engineering of existing cloud environments into +beautiful system architecture diagrams. + +This `cloudcraft-go` package provides an easy-to-use native Go SDK for interacting with [the Cloudcraft API](https://developers.cloudcraft.co/). + +Use case examples: +- Snapshot and visually compare your live AWS or Azure environment before and after a deployment, in your app or as part of your automated CI pipeline +- Download an inventory of all your cloud resources from a linked account as JSON +- Write a converter from a third party data format to Cloudcraft diagrams +- Backup, export & import your Cloudcraft data +- Programmatically create Cloudcraft diagrams + +This SDK requires a [Cloudcraft API key](https://developers.cloudcraft.co/#authentication) to use. [A free trial of Cloudcraft Pro](https://www.cloudcraft.co/pricing) with API access is available. + +## Installation + +To install `cloudcraft-go`, run: + +```console +go get github.com/DataDog/cloudcraft-go +``` + +## Go SDK Documentation + +Usage details and more examples, please [see the Go reference documentation](https://godocs.io/github.com/DataDog/cloudcraft-go). + +## Example Usage + +In the below example the Cloudcraft API key is read from the `CLOUDCRAFT_API_KEY` environment variable. Alternatively, pass in the key to the configuration directly. + +```go +package main + +import ( + "context" + "log" + "os" + + "github.com/DataDog/cloudcraft-go" +) + +func main() { + key, ok := os.LookupEnv("CLOUDCRAFT_API_KEY") + if !ok { + log.Fatal("missing env var: CLOUDCRAFT_API_KEY") + } + + // Create new Config to be initialize a Client. + cfg := cloudcraft.NewConfig(key) + + // Create a new Client instance with the given Config. + client, err := cloudcraft.NewClient(cfg) + if err != nil { + log.Fatal(err) + } + + // List all blueprints in an account. + blueprints, _, err := client.Blueprint.List(context.Background()) + if err != nil { + log.Fatal(err) + } + + // Print the name of each blueprint. + for _, blueprint := range blueprints { + log.Println(blueprint.Name) + } +} +``` + +## Contributing + +Anyone can help make `cloudcraft-go` better. Check out [the contribution guidelines](CONTRIBUTING.md) for more information. + +--- + +Released under the [Apache-2.0 License](LICENSE.md). diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..2ac4e64 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,5 @@ +## Support + +The issue queue we have here on GitHub is primarily intended for tracking features, bugs and work items associated with Cloudcraft's Go client. + +For any other support request, please [reach out to our support team](https://app.cloudcraft.co/app/support). diff --git a/aws.go b/aws.go new file mode 100644 index 0000000..99eadbb --- /dev/null +++ b/aws.go @@ -0,0 +1,417 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/DataDog/cloudcraft-go/internal/xerrors" +) + +// awsAccountPath is the path to the AWS endpoint of the Cloudcraft API. +const awsAccountPath string = "aws/account" + +const ( + // ErrEmptyRoleARN is returned when the AWS account's role ARN is empty. + ErrEmptyRoleARN xerrors.Error = "role ARN cannot be empty" +) + +const ( + // DefaultSnapshotFormat is the default format used for account snapshots. + DefaultSnapshotFormat string = "png" + + // DefaultSnapshotWidth is the default width used for account snapshots. + DefaultSnapshotWidth int = 1920 + + // DefaultSnapshotHeight is the default height used for account snapshots. + DefaultSnapshotHeight int = 1080 +) + +// AWSService handles communication with the "/aws" endpoint of Cloudcraft's +// developer API. +type AWSService service + +// AWSAccount represents an AWS account registered with Cloudcraft. +type AWSAccount struct { + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` + ReadAccess *[]string `json:"readAccess,omitempty"` + WriteAccess *[]string `json:"writeAccess,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + RoleARN string `json:"roleArn,omitempty"` + ExternalID string `json:"externalId,omitempty"` + CreatorID string `json:"CreatorId,omitempty"` + Source string `json:"source,omitempty"` +} + +// IAMParams represents the AWS IAM role parameters used by Cloudcraft. +type IAMParams struct { + AccountID string `json:"accountId,omitempty"` + ExternalID string `json:"externalId,omitempty"` + AWSConsoleURL string `json:"awsConsoleUrl,omitempty"` +} + +// IAMPolicy represents the AWS IAM policy used by Cloudcraft. +type IAMPolicy struct { + Version string `json:"Version,omitempty"` + Statement []IAMStatement `json:"Statement,omitempty"` +} + +// IAMStatement represents an AWS IAM policy statement. +type IAMStatement struct { + Action any `json:"Action,omitempty"` + Resource any `json:"Resource,omitempty"` + Effect string `json:"Effect,omitempty"` +} + +// List lists your AWS accounts linked with Cloudcraft. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#a83b30f1-8949-4c68-9944-2e2ab2710670 +func (s *AWSService) List(ctx context.Context) ([]*AWSAccount, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(awsAccountPath)) + + endpoint.WriteString(baseURL) + endpoint.WriteString(awsAccountPath) + + req, err := s.client.request(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + var result map[string][]*AWSAccount + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + accounts, ok := result["accounts"] + if !ok { + return nil, resp, fmt.Errorf("%w", ErrAccountsKey) + } + + return accounts, resp, nil +} + +// Create registers a new AWS account with Cloudcraft. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#51c4726e-ce1a-4e16-8b3f-f15dcee0aebe +func (s *AWSService) Create(ctx context.Context, account *AWSAccount) (*AWSAccount, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + if account == nil { + return nil, nil, ErrNilAccount + } + + if account.Name == "" { + return nil, nil, ErrEmptyAccountName + } + + if account.RoleARN == "" { + return nil, nil, ErrEmptyRoleARN + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(awsAccountPath)) + + endpoint.WriteString(baseURL) + endpoint.WriteString(awsAccountPath) + + payload, err := json.Marshal(account) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + req, err := s.client.request(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + var result *AWSAccount + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + return result, resp, nil +} + +// Update updates an AWS account registered in Cloudcraft. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#d04fdf78-ea33-4846-a8b2-bb5e693e8f64 +func (s *AWSService) Update(ctx context.Context, account *AWSAccount) (*Response, error) { + if ctx == nil { + return nil, ErrNilContext + } + + if account == nil { + return nil, ErrNilAccount + } + + if account.ID == "" { + return nil, ErrEmptyAccountID + } + + if account.Name == "" { + return nil, ErrEmptyAccountName + } + + if account.RoleARN == "" { + return nil, ErrEmptyRoleARN + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(awsAccountPath) + len(account.ID) + 1) + + endpoint.WriteString(baseURL) + endpoint.WriteString(awsAccountPath) + endpoint.WriteByte('/') + endpoint.WriteString(account.ID) + + payload, err := json.Marshal(account) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + req, err := s.client.request(ctx, http.MethodPut, endpoint.String(), bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return resp, fmt.Errorf("%w", err) + } + + return resp, nil +} + +// Delete deletes a registered AWS account from Cloudcraft by ID. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#c4788665-d408-4535-8aa1-bf27dfb064aa +func (s *AWSService) Delete(ctx context.Context, id string) (*Response, error) { + if ctx == nil { + return nil, ErrNilContext + } + + if id == "" { + return nil, ErrEmptyAccountID + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(awsAccountPath) + len(id) + 1) + + endpoint.WriteString(baseURL) + endpoint.WriteString(awsAccountPath) + endpoint.WriteByte('/') + endpoint.WriteString(id) + + req, err := s.client.request(ctx, http.MethodDelete, endpoint.String(), http.NoBody) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return resp, fmt.Errorf("%w", err) + } + + return resp, nil +} + +// Snapshot scans and render a region of an AWS account into a blueprint in +// JSON, SVG, PNG, PDF or MxGraph format. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#13e7daaf-e22a-42c6-b6bc-e34a24f05e60 +func (s *AWSService) Snapshot( + ctx context.Context, + id, region, format string, + params *SnapshotParams, +) ([]byte, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + if id == "" { + return nil, nil, ErrEmptyAccountID + } + + if region == "" { + return nil, nil, ErrEmptyRegion + } + + if format == "" { + format = DefaultSnapshotFormat + } + + if params == nil { + params = &SnapshotParams{ + Width: DefaultSnapshotWidth, + Height: DefaultSnapshotHeight, + } + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(awsAccountPath) + len(id) + len(region) + len(format) + 3) + + endpoint.WriteString(baseURL) + endpoint.WriteString(awsAccountPath) + endpoint.WriteByte('/') + endpoint.WriteString(id) + endpoint.WriteByte('/') + endpoint.WriteString(region) + endpoint.WriteByte('/') + endpoint.WriteString(format) + + u, err := url.Parse(endpoint.String()) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + u.RawQuery = params.query().Encode() + + req, err := s.client.request(ctx, http.MethodGet, u.String(), http.NoBody) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + return resp.Body, resp, nil +} + +// IAMParameters list all parameters required for registering a new IAM Role in +// AWS for use with Cloudcraft. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#aa18999e-f6da-4628-96bd-49d5a286b928 +func (s *AWSService) IAMParameters(ctx context.Context) (*IAMParams, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(awsAccountPath) + len("/iamParameters")) + + endpoint.WriteString(baseURL) + endpoint.WriteString(awsAccountPath) + endpoint.WriteString("/iamParameters") + + req, err := s.client.request(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + var result *IAMParams + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + return result, resp, nil +} + +// IAMPolicy lists all permissions required for registering a new IAM Role in AWS for use with Cloudcraft. +// +// [API reference]. +// +// [API reference]: https://help.cloudcraft.co/article/64-minimal-iam-policy +func (s *AWSService) IAMPolicy(ctx context.Context) (*IAMPolicy, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(awsAccountPath) + len("/iamParameters/policy/minimal")) + + endpoint.WriteString(baseURL) + endpoint.WriteString(awsAccountPath) + endpoint.WriteString("/iamParameters/policy/minimal") + + req, err := s.client.request(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + var result *IAMPolicy + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + return result, resp, nil +} diff --git a/aws_test.go b/aws_test.go new file mode 100644 index 0000000..d05cf30 --- /dev/null +++ b/aws_test.go @@ -0,0 +1,852 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft_test + +import ( + "bytes" + "context" + "image/png" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/DataDog/cloudcraft-go" + "github.com/DataDog/cloudcraft-go/internal/xtesting" +) + +const _testAWSDataPath string = "tests/data/aws" + +func TestAWSService_List(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testAWSDataPath, "list-valid.json")) + invalidTestData = xtesting.ReadFile(t, filepath.Join(_testAWSDataPath, "generic-invalid.json")) + emptyTestData = xtesting.ReadFile(t, filepath.Join(_testAWSDataPath, "list-empty.json")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + want []*cloudcraft.AWSAccount + wantErr bool + }{ + { + name: "Valid AWS account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + want: []*cloudcraft.AWSAccount{ + { + ID: "47830a91-51b7-4708-b9b2-5f3d121fc039", + Name: "Go SDK Test", + RoleARN: "arn:aws:iam::558791803304:role/cloudcraft", + ExternalID: "61fc01d6-3e6f-47ab-bc44-53fab97c217a", + ReadAccess: &[]string{ + "team/5f209338-50a1-495f-90dd-73251dec7329", + "team/d7cd0211-85a7-45fc-9292-8d5c62cef70a", + }, + WriteAccess: &[]string{ + "team/5f209338-50a1-495f-90dd-73251dec7329", + "team/d7cd0211-85a7-45fc-9292-8d5c62cef70a", + }, + CreatedAt: xtesting.ParseTime(t, "2019-02-19T16:20:34.042Z"), + UpdatedAt: xtesting.ParseTime(t, "2022-08-05T18:13:05.625Z"), + CreatorID: "280ccb78-6a06-4e28-adad-8d16d413be50", + Source: "aws", + }, + }, + wantErr: false, + }, + { + name: "Invalid AWS account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(invalidTestData) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "Empty AWS account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(emptyTestData) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.AWS.List(tt.context) + if (err != nil) != tt.wantErr { + t.Fatalf("AWSService.List() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("AWSService.List() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAWSService_Create(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testAWSDataPath, "create-valid.json")) + invalidTestData = xtesting.ReadFile(t, filepath.Join(_testAWSDataPath, "generic-invalid.json")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + give *cloudcraft.AWSAccount + want *cloudcraft.AWSAccount + wantErr bool + }{ + { + name: "Valid AWS account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + + w.Write(validTestData) + }, + context: ctx, + give: &cloudcraft.AWSAccount{ + Name: "Go SDK Test", + RoleARN: "arn:aws:iam::558791803304:role/cloudcraft", + ExternalID: "8a8a745a-d01f-4541-8ab0-e3558e7c6b1c", + }, + want: &cloudcraft.AWSAccount{ + ID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + Name: "Go SDK Test", + RoleARN: "arn:aws:iam::558791803304:role/cloudcraft", + ExternalID: "8a8a745a-d01f-4541-8ab0-e3558e7c6b1c", + ReadAccess: nil, + WriteAccess: nil, + CreatedAt: xtesting.ParseTime(t, "2019-02-19T16:20:34.042Z"), + UpdatedAt: xtesting.ParseTime(t, "2022-08-05T18:13:05.625Z"), + CreatorID: "17d5fe91-9efb-4b1a-90cd-0b885b1d43b9", + }, + wantErr: false, + }, + { + name: "Invalid AWS account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(invalidTestData) + }, + context: ctx, + give: &cloudcraft.AWSAccount{ + Name: "Go SDK Test", + RoleARN: "arn:aws:iam::643880554691j:role/cloudcraft", + ExternalID: "8a8a745a-d01f-4541-8ab0-e3558e7c6b1c", + }, + want: nil, + wantErr: true, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }, + context: ctx, + give: &cloudcraft.AWSAccount{ + Name: "Go SDK Test", + RoleARN: "arn:aws:iam::643880554691j:role/cloudcraft", + ExternalID: "8a8a745a-d01f-4541-8ab0-e3558e7c6b1c", + }, + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + give: &cloudcraft.AWSAccount{ + Name: "Go SDK Test", + RoleARN: "arn:aws:iam::643880554691j:role/cloudcraft", + ExternalID: "8a8a745a-d01f-4541-8ab0-e3558e7c6b1c", + }, + want: nil, + wantErr: true, + }, + { + name: "Nil AWS account", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: nil, + want: nil, + wantErr: true, + }, + { + name: "Empty Name", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AWSAccount{ + Name: "", + RoleARN: "arn:aws:iam::643880554691j:role/cloudcraft", + ExternalID: "8a8a745a-d01f-4541-8ab0-e3558e7c6b1c", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty RoleARN", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AWSAccount{ + Name: "Go SDK Test", + RoleARN: "", + ExternalID: "8a8a745a-d01f-4541-8ab0-e3558e7c6b1c", + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.AWS.Create(tt.context, tt.give) + if (err != nil) != tt.wantErr { + t.Fatalf("AWSAccount.Create() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("AWSAccount.Create() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAWSService_Update(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + give *cloudcraft.AWSAccount + want *cloudcraft.Response + wantErr bool + }{ + { + name: "Valid AWS account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + context: ctx, + give: &cloudcraft.AWSAccount{ + ID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + Name: "My updated AWS account", + RoleARN: "arn:aws:iam::558791803304:role/cloudcraft", + }, + want: &cloudcraft.Response{ + Header: http.Header{ + "Date": []string{ + time.Now().In(time.UTC).Format(http.TimeFormat), + }, + }, + Body: []uint8{}, + Status: http.StatusNoContent, + }, + wantErr: false, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }, + context: ctx, + give: &cloudcraft.AWSAccount{ + ID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + Name: "My updated AWS account", + RoleARN: "arn:aws:iam::558791803304:role/cloudcraft", + }, + want: nil, + wantErr: true, + }, + { + name: "Nil AWS account", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: nil, + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + give: &cloudcraft.AWSAccount{ + ID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + Name: "My updated AWS account", + RoleARN: "arn:aws:iam::558791803304:role/cloudcraft", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AWSAccount{ + Name: "My updated AWS account", + RoleARN: "arn:aws:iam::558791803304:role/cloudcraft", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty name", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AWSAccount{ + ID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + RoleARN: "arn:aws:iam::558791803304:role/cloudcraft", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty role ARN", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AWSAccount{ + ID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + Name: "My updated AWS account", + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, err := client.AWS.Update(tt.context, tt.give) + if (err != nil) != tt.wantErr { + t.Fatalf("AWS().Update() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("AWS().Update() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAWSService_Delete(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + give string + want *cloudcraft.Response + wantErr bool + }{ + { + name: "Valid AWS account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + context: ctx, + give: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + want: &cloudcraft.Response{ + Header: http.Header{ + "Date": []string{ + time.Now().In(time.UTC).Format(http.TimeFormat), + }, + }, + Body: []uint8{}, + Status: http.StatusNoContent, + }, + wantErr: false, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }, + context: ctx, + give: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + give: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + want: nil, + wantErr: true, + }, + { + name: "Empty ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: "", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, err := client.AWS.Delete(tt.context, tt.give) + if (err != nil) != tt.wantErr { + t.Fatalf("AWS().Delete() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("AWS().Delete() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAWSService_Snapshot(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testAWSDataPath, "snapshot-valid.png")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + giveID string + giveRegion string + giveFormat string + giveParams *cloudcraft.SnapshotParams + wantWidth int + wantHeight int + wantErr bool + }{ + { + name: "Valid AWS account snapshot", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + giveRegion: "us-east-1", + giveFormat: "png", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: false, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }, + context: ctx, + giveID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + giveRegion: "us-east-1", + giveFormat: "png", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + giveID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + giveRegion: "us-east-1", + giveFormat: "png", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantErr: true, + }, + { + name: "Nil snapshot params", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + giveRegion: "us-east-1", + giveFormat: "png", + giveParams: nil, + wantWidth: 1920, + wantHeight: 1080, + wantErr: false, + }, + { + name: "Empty format", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + giveRegion: "us-east-1", + giveFormat: "", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: false, + }, + { + name: "Empty ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + giveID: "", + giveRegion: "us-east-1", + giveFormat: "png", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantErr: true, + }, + { + name: "Empty region", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + giveID: "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + giveRegion: "", + giveFormat: "png", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.AWS.Snapshot(tt.context, tt.giveID, tt.giveRegion, tt.giveFormat, tt.giveParams) + if (err != nil) != tt.wantErr { + t.Fatalf("AWSService.Snapshot() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + gotPNG, err := png.DecodeConfig(bytes.NewReader(got)) + if err != nil { + t.Fatal(err) + } + + if gotPNG.Width != tt.wantWidth { + t.Fatalf("Blueprint.Export() width = %v, want %v", gotPNG.Width, tt.wantWidth) + } + + if gotPNG.Height != tt.wantHeight { + t.Fatalf("Blueprint.Export() height = %v, want %v", gotPNG.Height, tt.wantHeight) + } + + if !bytes.Equal(got, validTestData) { + t.Fatalf("Blueprint.Export() = %v, want %v", got, validTestData) + } + } + }) + } +} + +func TestAWSService_IAMParameters(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testAWSDataPath, "iam-parameters-valid.json")) + invalidTestData = xtesting.ReadFile(t, filepath.Join(_testAWSDataPath, "generic-invalid.json")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + want *cloudcraft.IAMParams + wantErr bool + }{ + { + name: "Valid IAM parameters data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + want: &cloudcraft.IAMParams{ + AccountID: "912185983511", + ExternalID: "4414aef4-8f04-4b0b-8425-d73b84dcaa2d", + AWSConsoleURL: "https://console.aws.amazon.com/iam/home?#/roles$new?step=type&roleType=crossAccount&isThirdParty&accountID=912185983511&externalID=4414aef4-8f04-4b0b-8425-d73b84dcaa2d&roleName=cloudcraft&policies=arn:aws:iam::aws:policy%2FReadOnlyAccess", + }, + wantErr: false, + }, + { + name: "Invalid IAM parameters data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(invalidTestData) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.AWS.IAMParameters(tt.context) + if (err != nil) != tt.wantErr { + t.Fatalf("AWSService.IAMParameters() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("AWSService.IAMParameters() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAWSService_IAMPolicy(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testAWSDataPath, "iam-policy-valid.json")) + invalidTestData = xtesting.ReadFile(t, filepath.Join(_testAWSDataPath, "generic-invalid.json")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + want *cloudcraft.IAMPolicy + wantErr bool + }{ + { + name: "Valid IAM policy data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + want: &cloudcraft.IAMPolicy{ + Version: "2012-10-17", + Statement: []cloudcraft.IAMStatement{ + { + Effect: "Allow", + Action: string("apigateway:GET"), + Resource: []any{ + string("arn:aws:apigateway:*::/apis"), + string("arn:aws:apigateway:*::/apis/*"), + string("..."), + }, + }, + { + Effect: "Allow", + Action: []any{ + string("autoscaling:DescribeAutoScalingGroups"), + string("cassandra:Select"), + string("..."), + }, + Resource: string("*"), + }, + }, + }, + wantErr: false, + }, + { + name: "Invalid IAM policy data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(invalidTestData) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.AWS.IAMPolicy(tt.context) + if (err != nil) != tt.wantErr { + t.Fatalf("AWSService.IAMPolicy() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("AWSService.IAMPolicy() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/azure.go b/azure.go new file mode 100644 index 0000000..66d47ba --- /dev/null +++ b/azure.go @@ -0,0 +1,348 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/DataDog/cloudcraft-go/internal/xerrors" +) + +// azureAccountPath is the path to the Azure endpoint of the Cloudcraft API. +const azureAccountPath string = "azure/account" + +const ( + // ErrEmptyApplicationID is returned when an Azure account is created with + // an empty application ID. + ErrEmptyApplicationID xerrors.Error = "field 'ApplicationID' cannot be empty" + + // ErrEmptyDirectoryID is returned when an Azure account is created with + // an empty directory ID. + ErrEmptyDirectoryID xerrors.Error = "field 'DirectoryID' cannot be empty" + + // ErrEmptySubscriptionID is returned when an Azure account is created with + // an empty subscription ID. + ErrEmptySubscriptionID xerrors.Error = "field 'SubscriptionID' cannot be empty" + + // ErrEmptyClientSecret is returned when an Azure account is created with + // an empty client secret. + ErrEmptyClientSecret xerrors.Error = "field 'ClientSecret' cannot be empty" +) + +// AzureService handles communication with the "/azure" endpoint of Cloudcraft's +// developer API. +type AzureService service + +// AzureAccount represents an Azure account registered with Cloudcraft. +type AzureAccount struct { + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` + ReadAccess *[]string `json:"readAccess,omitempty"` + WriteAccess *[]string `json:"writeAccess,omitempty"` + CustomerID *string `json:"CustomerId,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + DirectoryID string `json:"directoryId,omitempty"` + SubscriptionID string `json:"subscriptionId,omitempty"` + ClientSecret string `json:"clientSecret,omitempty"` + CreatorID string `json:"CreatorId,omitempty"` + Hint string `json:"hint,omitempty"` + Source string `json:"source,omitempty"` +} + +// List returns a list of Azure accounts linked with Cloudcraft. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#29470635-2970-4205-8256-85c5835b92a1 +func (s *AzureService) List(ctx context.Context) ([]*AzureAccount, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(azureAccountPath)) + + endpoint.WriteString(baseURL) + endpoint.WriteString(azureAccountPath) + + req, err := s.client.request(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + var result map[string][]*AzureAccount + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + accounts, ok := result["accounts"] + if !ok { + return nil, resp, fmt.Errorf("%w", ErrAccountsKey) + } + + return accounts, resp, nil +} + +// Create registers a new Azure account with Cloudcraft. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#09a9a67d-c807-45c1-b8a8-f5a6df08da12 +func (s *AzureService) Create(ctx context.Context, account *AzureAccount) (*AzureAccount, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + if account == nil { + return nil, nil, ErrNilAccount + } + + if account.Name == "" { + return nil, nil, ErrEmptyAccountName + } + + if account.ApplicationID == "" { + return nil, nil, ErrEmptyApplicationID + } + + if account.DirectoryID == "" { + return nil, nil, ErrEmptyDirectoryID + } + + if account.SubscriptionID == "" { + return nil, nil, ErrEmptySubscriptionID + } + + if account.ClientSecret == "" { + return nil, nil, ErrEmptyClientSecret + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(azureAccountPath)) + + endpoint.WriteString(baseURL) + endpoint.WriteString(azureAccountPath) + + payload, err := json.Marshal(account) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + req, err := s.client.request(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + var result *AzureAccount + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + return result, resp, nil +} + +// Update updates an AWS account registered in Cloudcraft. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#d04fdf78-ea33-4846-a8b2-bb5e693e8f64 +func (s *AzureService) Update(ctx context.Context, account *AzureAccount) (*Response, error) { + if ctx == nil { + return nil, ErrNilContext + } + + if account == nil { + return nil, ErrNilAccount + } + + if account.ID == "" { + return nil, ErrEmptyAccountID + } + + if account.Name == "" { + return nil, ErrEmptyAccountName + } + + if account.ApplicationID == "" { + return nil, ErrEmptyApplicationID + } + + if account.DirectoryID == "" { + return nil, ErrEmptyDirectoryID + } + + if account.SubscriptionID == "" { + return nil, ErrEmptySubscriptionID + } + + if account.ClientSecret == "" { + return nil, ErrEmptyClientSecret + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(azureAccountPath) + len(account.ID) + 1) + + endpoint.WriteString(baseURL) + endpoint.WriteString(azureAccountPath) + endpoint.WriteByte('/') + endpoint.WriteString(account.ID) + + payload, err := json.Marshal(account) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + req, err := s.client.request(ctx, http.MethodPut, endpoint.String(), bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return resp, fmt.Errorf("%w", err) + } + + return resp, nil +} + +// Delete deletes a registered AWS account from Cloudcraft by ID. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#c4788665-d408-4535-8aa1-bf27dfb064aa +func (s *AzureService) Delete(ctx context.Context, id string) (*Response, error) { + if ctx == nil { + return nil, ErrNilContext + } + + if id == "" { + return nil, ErrEmptyAccountID + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(azureAccountPath) + len(id) + 1) + + endpoint.WriteString(baseURL) + endpoint.WriteString(azureAccountPath) + endpoint.WriteByte('/') + endpoint.WriteString(id) + + req, err := s.client.request(ctx, http.MethodDelete, endpoint.String(), http.NoBody) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return resp, fmt.Errorf("%w", err) + } + + return resp, nil +} + +// Snapshot scans and render a region of an Azure account into a blueprint in +// JSON, SVG, PNG, PDF or MxGraph format. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#e687cfa9-f181-4eaf-bf76-f167235fa4fe +func (s *AzureService) Snapshot( + ctx context.Context, + id, region, format string, + params *SnapshotParams, +) ([]byte, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + if id == "" { + return nil, nil, ErrEmptyAccountID + } + + if region == "" { + return nil, nil, ErrEmptyRegion + } + + if format == "" { + format = DefaultSnapshotFormat + } + + if params == nil { + params = &SnapshotParams{ + Width: DefaultSnapshotWidth, + Height: DefaultSnapshotHeight, + } + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(azureAccountPath) + len(id) + len(region) + len(format) + 3) + + endpoint.WriteString(baseURL) + endpoint.WriteString(azureAccountPath) + endpoint.WriteByte('/') + endpoint.WriteString(id) + endpoint.WriteByte('/') + endpoint.WriteString(region) + endpoint.WriteByte('/') + endpoint.WriteString(format) + + u, err := url.Parse(endpoint.String()) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + u.RawQuery = params.query().Encode() + + req, err := s.client.request(ctx, http.MethodGet, u.String(), http.NoBody) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, resp, fmt.Errorf("%w", err) + } + + return resp.Body, resp, nil +} diff --git a/azure_test.go b/azure_test.go new file mode 100644 index 0000000..564d6a4 --- /dev/null +++ b/azure_test.go @@ -0,0 +1,791 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft_test + +import ( + "bytes" + "context" + "image/png" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/DataDog/cloudcraft-go" + "github.com/DataDog/cloudcraft-go/internal/xtesting" +) + +const _testAzureDataPath string = "tests/data/azure" + +func TestAzureService_List(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testAzureDataPath, "list-valid.json")) + invalidTestData = xtesting.ReadFile(t, filepath.Join(_testAzureDataPath, "generic-invalid.json")) + emptyTestData = xtesting.ReadFile(t, filepath.Join(_testAzureDataPath, "list-empty.json")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + want []*cloudcraft.AzureAccount + wantErr bool + }{ + { + name: "Valid Azure account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + want: []*cloudcraft.AzureAccount{ + { + ID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ReadAccess: &[]string{}, + WriteAccess: &[]string{}, + CreatedAt: xtesting.ParseTime(t, "2023-03-15T20:42:52.704Z"), + UpdatedAt: xtesting.ParseTime(t, "2023-03-15T20:43:10.171Z"), + CreatorID: "6935c7da-cdfb-4885-902c-25aa00720ab4", + Hint: "3RK", + Source: "azure", + }, + }, + wantErr: false, + }, + { + name: "Invalid Azure account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(invalidTestData) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "Empty Azure account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(emptyTestData) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.Azure.List(tt.context) + if (err != nil) != tt.wantErr { + t.Fatalf("AzureService.List() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("AzureService.List() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAzureService_Create(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testAzureDataPath, "create-valid.json")) + invalidTestData = xtesting.ReadFile(t, filepath.Join(_testAzureDataPath, "generic-invalid.json")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + give *cloudcraft.AzureAccount + want *cloudcraft.AzureAccount + wantErr bool + }{ + { + name: "Valid Azure account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + + w.Write(validTestData) + }, + context: ctx, + give: &cloudcraft.AzureAccount{ + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: &cloudcraft.AzureAccount{ + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + ID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ReadAccess: nil, + WriteAccess: nil, + CreatorID: "6935c7da-cdfb-4885-902c-25aa00720ab4", + UpdatedAt: xtesting.ParseTime(t, "2023-11-20T22:11:43.688Z"), + CreatedAt: xtesting.ParseTime(t, "2023-11-20T22:11:43.688Z"), + CustomerID: nil, + }, + wantErr: false, + }, + { + name: "Invalid Azure account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + + w.Write(invalidTestData) + }, + context: ctx, + give: &cloudcraft.AzureAccount{ + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + give: &cloudcraft.AzureAccount{ + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Nil Azure account", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: nil, + want: nil, + wantErr: true, + }, + { + name: "Empty name", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + Name: "", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty application ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + Name: "Go SDK Test", + ApplicationID: "", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty directory ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty subscription ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty client secret", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "", + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.Azure.Create(tt.context, tt.give) + if (err != nil) != tt.wantErr { + t.Fatalf("Create() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Create() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAzureService_Update(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + give *cloudcraft.AzureAccount + want *cloudcraft.Response + wantErr bool + }{ + { + name: "Valid Azure account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + context: ctx, + give: &cloudcraft.AzureAccount{ + ID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: &cloudcraft.Response{ + Header: http.Header{ + "Date": []string{ + time.Now().In(time.UTC).Format(http.TimeFormat), + }, + }, + Body: []uint8{}, + Status: http.StatusNoContent, + }, + wantErr: false, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }, + context: ctx, + give: &cloudcraft.AzureAccount{ + ID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Nil Azure account", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: nil, + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + give: &cloudcraft.AzureAccount{ + ID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + ID: "", + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty name", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + ID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + Name: "", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty Application ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + ID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + Name: "Go SDK Test", + ApplicationID: "", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty Directory ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + ID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty Subscription ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + ID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "", + ClientSecret: "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + }, + want: nil, + wantErr: true, + }, + { + name: "Empty Client Secret", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.AzureAccount{ + ID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + Name: "Go SDK Test", + ApplicationID: "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + DirectoryID: "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + SubscriptionID: "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + ClientSecret: "", + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, err := client.Azure.Update(tt.context, tt.give) + if (err != nil) != tt.wantErr { + t.Fatalf("Azure.Update() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Azure.Update() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAzureService_Delete(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + give string + want *cloudcraft.Response + wantErr bool + }{ + { + name: "Valid Azure account data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + context: ctx, + give: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + want: &cloudcraft.Response{ + Header: http.Header{ + "Date": []string{ + time.Now().In(time.UTC).Format(http.TimeFormat), + }, + }, + Body: []uint8{}, + Status: http.StatusNoContent, + }, + wantErr: false, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }, + context: ctx, + give: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + give: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + want: nil, + wantErr: true, + }, + { + name: "Empty ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: "", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, err := client.Azure.Delete(tt.context, tt.give) + if (err != nil) != tt.wantErr { + t.Fatalf("Azure.Delete() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Azure.Delete() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAzureService_Snapshot(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testAzureDataPath, "snapshot-valid.png")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + giveID string + giveRegion string + giveFormat string + giveParams *cloudcraft.SnapshotParams + wantWidth int + wantHeight int + wantErr bool + }{ + { + name: "Valid Azure account snapshot", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + giveRegion: "centralus", + giveFormat: "png", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: false, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + }, + context: ctx, + giveID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + giveRegion: "centralus", + giveFormat: "png", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + giveID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + giveRegion: "centralus", + giveFormat: "png", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: true, + }, + { + name: "Nil snapshot params", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + giveRegion: "centralus", + giveFormat: "png", + giveParams: nil, + wantWidth: 1920, + wantHeight: 1080, + wantErr: false, + }, + { + name: "Empty ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + giveID: "", + giveRegion: "centralus", + giveFormat: "png", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: true, + }, + { + name: "Empty region", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + giveID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + giveRegion: "", + giveFormat: "png", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: true, + }, + { + name: "Empty format", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + giveRegion: "centralus", + giveFormat: "", + giveParams: &cloudcraft.SnapshotParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: false, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.Azure.Snapshot(tt.context, tt.giveID, tt.giveRegion, tt.giveFormat, tt.giveParams) + if (err != nil) != tt.wantErr { + t.Fatalf("AzureService.Snapshot() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + gotPNG, err := png.DecodeConfig(bytes.NewReader(got)) + if err != nil { + t.Fatal(err) + } + + if gotPNG.Width != tt.wantWidth { + t.Fatalf("Azure.Snapshot() width = %v, want %v", gotPNG.Width, tt.wantWidth) + } + + if gotPNG.Height != tt.wantHeight { + t.Fatalf("Azure.Snapshot() height = %v, want %v", gotPNG.Height, tt.wantHeight) + } + + if !bytes.Equal(got, validTestData) { + t.Fatalf("Azure.Snapshot() = %v, want %v", got, validTestData) + } + } + }) + } +} diff --git a/blueprint.go b/blueprint.go new file mode 100644 index 0000000..069704f --- /dev/null +++ b/blueprint.go @@ -0,0 +1,550 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/DataDog/cloudcraft-go/internal/xerrors" +) + +// blueprintPath is the path to the blueprint endpoint of the Cloudcraft API. +const blueprintPath string = "blueprint" + +const ( + // ErrNilBlueprint is returned when you try to send a request without a + // blueprint. + ErrNilBlueprint xerrors.Error = "blueprint cannot be nil" + + // ErrBlueprintKey is returned when the response from the API does to a List + // call is not a list of blueprints. + ErrBlueprintKey xerrors.Error = "key 'blueprints' not found in the response" + + // ErrMissingID is returned when you try to send a request without the ID of + // a blueprint. + ErrMissingBlueprintID xerrors.Error = "missing blueprint ID" +) + +const ( + // DefaultImageExportFormat is the default format used to export blueprint + // images. + DefaultImageExportFormat string = "png" + + // DefaultImageExportWidth is the default width used to export blueprint + // images. + DefaultImageExportWidth int = 1920 + + // DefaultImageExportHeight is the default height used to export blueprint + // images. + DefaultImageExportHeight int = 1080 + + // DefaultBudgetExportFormat is the default format used to export a + // blueprint's budget. + DefaultBudgetExportFormat string = "csv" + + // DefaultBudgetExportCurrency is the default currency used to export a + // blueprint's budget. + DefaultBudgetExportCurrency string = "USD" + + // DefaultBudgetExportPeriod is the default period used to export a blueprint's + // budget. + DefaultBudgetExportPeriod string = "m" +) + +// BlueprintService handles communication with the "/blueprint" endpoint of +// Cloudcraft's developer API. +type BlueprintService service + +// Blueprint represents a blueprint in Cloudcraft. +type Blueprint struct { + CustomerID *string `json:"CustomerId,omitempty"` + ReadAccess *[]string `json:"readAccess,omitempty"` + WriteAccess *[]string `json:"writeAccess,omitempty"` + Tags *[]string `json:"tags,omitempty"` + Data *BlueprintData `json:"data,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + CreatorID string `json:"CreatorId,omitempty"` + CurrentVersionID string `json:"CurrentVersionId,omitempty"` + LastUserID string `json:"LastUserId,omitempty"` +} + +// BlueprintData represents a collection of data that makes up a blueprint. +type BlueprintData struct { + LiveAccount *LiveAccount `json:"liveAccount,omitempty"` + Theme *Theme `json:"theme,omitempty"` + LiveOptions *LiveOptions `json:"liveOptions,omitempty"` + Name string `json:"name,omitempty"` + Projection string `json:"projection,omitempty"` + LinkKey string `json:"linkKey,omitempty"` + Grid string `json:"grid,omitempty"` + Images []map[string]any `json:"images,omitempty"` + Groups []map[string]any `json:"groups,omitempty"` + Nodes []map[string]any `json:"nodes,omitempty"` + Icons []map[string]any `json:"icons,omitempty"` + Surfaces []map[string]any `json:"surfaces,omitempty"` + Connectors []map[string]any `json:"connectors,omitempty"` + Edges []map[string]any `json:"edges,omitempty"` + Text []map[string]any `json:"text,omitempty"` + DisabledLayers []string `json:"disabledLayers,omitempty"` + Version int `json:"version,omitempty"` + ShareDocs bool `json:"shareDocs,omitempty"` +} + +// Theme represents the color scheme of a blueprint. +type Theme struct { + Base string `json:"base,omitempty"` +} + +// LiveAccount represents the AWS account that a blueprint is connected to. +type LiveAccount struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` +} + +// LiveOptions represents options for a blueprint's live view. +type LiveOptions struct { + ExcludedTypes []string `json:"excludedTypes,omitempty"` + AutoLabel bool `json:"autoLabel,omitempty"` + AutoConnect bool `json:"autoConnect,omitempty"` + UpdatesEnabled bool `json:"updatesEnabled,omitempty"` + UpdateAllOnScan bool `json:"updateAllOnScan,omitempty"` + UpdateGroupsOnScan bool `json:"updateGroupsOnScan,omitempty"` + UpdateNodeOnSelect bool `json:"updateNodeOnSelect,omitempty"` +} + +// ImageExportParams represents optional query parameters that can be used to +// customize an image export. +type ImageExportParams struct { + PaperSize string + Grid bool + Transparent bool + Landscape bool + Scale float32 + Width int + Height int +} + +// query builds a query string from fields with non-zero values and returns it +// as url.Values. +func (p *ImageExportParams) query() url.Values { + values := make(url.Values) + + if p.PaperSize != "" { + values["paperSize"] = []string{p.PaperSize} + } + + if p.Grid { + values["grid"] = []string{"true"} + } + + if p.Transparent { + values["transparent"] = []string{"true"} + } + + if p.Landscape { + values["landscape"] = []string{"true"} + } + + if p.Scale != 0 { + scaleStr := strconv.FormatFloat(float64(p.Scale), 'f', -1, 32) + + values["scale"] = []string{scaleStr} + } + + if p.Width != 0 { + values["width"] = []string{strconv.Itoa(p.Width)} + } + + if p.Height != 0 { + values["height"] = []string{strconv.Itoa(p.Height)} + } + + return values +} + +// BudgetExportParams represents optional query parameters that can be used to +// customize an a budget export. +type BudgetExportParams struct { + Currency string + Period string + Rate string +} + +// query builds a query string from fields with non-zero values and returns it +// as url.Values. +func (p *BudgetExportParams) query() url.Values { + values := url.Values{} + + if p.Currency != "" { + values.Set("currency", p.Currency) + } + + if p.Period != "" { + values.Set("period", p.Period) + } + + if p.Rate != "" { + values.Set("rate", p.Rate) + } + + return values +} + +// List returns a list of blueprints. +// +// [API Reference]. +// +// [API Reference]: https://developers.cloudcraft.co/#19d9d681-b3b7-4950-a0e0-aeb518101714 +func (s *BlueprintService) List(ctx context.Context) ([]*Blueprint, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(blueprintPath)) + + endpoint.WriteString(baseURL) + endpoint.WriteString(blueprintPath) + + req, err := s.client.request(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + ret, err := s.client.do(ctx, req) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + var result map[string][]*Blueprint + if err := json.Unmarshal(ret.Body, &result); err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + blueprints, ok := result["blueprints"] + if !ok { + return nil, nil, fmt.Errorf("%w", ErrBlueprintKey) + } + + return blueprints, ret, nil +} + +// Get retrieves a blueprint by its ID. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#dfc05b6e-a851-46aa-8019-c839eae7d695 +func (s *BlueprintService) Get(ctx context.Context, id string) (*Blueprint, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + if id == "" { + return nil, nil, ErrMissingBlueprintID + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(blueprintPath) + len(id) + 1) + + endpoint.WriteString(baseURL) + endpoint.WriteString(blueprintPath) + endpoint.WriteString("/" + id) + + req, err := s.client.request(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + var result *Blueprint + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + return result, resp, nil +} + +// Create creates a new blueprint. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#d72c9b37-9f03-4c24-98d0-92971493780f +func (s *BlueprintService) Create(ctx context.Context, blueprint *Blueprint) (*Blueprint, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + if blueprint == nil { + return nil, nil, ErrNilBlueprint + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(blueprintPath)) + + endpoint.WriteString(baseURL) + endpoint.WriteString(blueprintPath) + + payload, err := json.Marshal(blueprint) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + req, err := s.client.request(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload)) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + var result *Blueprint + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + return result, resp, nil +} + +// Update updates an existing blueprint. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#7139bd5a-cf80-4bff-b2da-be0d35250b8f +func (s *BlueprintService) Update(ctx context.Context, blueprint *Blueprint, etag string) (*Response, error) { + if ctx == nil { + return nil, ErrNilContext + } + + if blueprint == nil { + return nil, ErrNilBlueprint + } + + if blueprint.ID == "" { + return nil, ErrMissingBlueprintID + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(blueprintPath) + len(blueprint.ID) + 1) + + endpoint.WriteString(baseURL) + endpoint.WriteString(blueprintPath) + endpoint.WriteString("/" + blueprint.ID) + + payload, err := json.Marshal(blueprint) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + req, err := s.client.request(ctx, http.MethodPut, endpoint.String(), bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + if etag != "" { + req.Header.Set("If-Match", etag) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + return resp, nil +} + +// Delete deletes a blueprint by ID. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#38e2767f-7b42-4573-85ba-6137b61fe0ef +func (s *BlueprintService) Delete(ctx context.Context, id string) (*Response, error) { + if ctx == nil { + return nil, ErrNilContext + } + + if id == "" { + return nil, ErrMissingBlueprintID + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(blueprintPath) + len(id) + 1) + + endpoint.WriteString(baseURL) + endpoint.WriteString(blueprintPath) + endpoint.WriteString("/" + id) + + req, err := s.client.request(ctx, http.MethodDelete, endpoint.String(), http.NoBody) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + return resp, nil +} + +// ExportImage renders a blueprint for export in SVG, PNG, PDF or MxGraph format. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#8ad8ffa1-4a34-44e1-8795-4a851fc2fa58 +func (s *BlueprintService) ExportImage( + ctx context.Context, + id string, + format string, + params *ImageExportParams, +) ([]byte, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + if id == "" { + return nil, nil, ErrMissingBlueprintID + } + + if format == "" { + format = DefaultImageExportFormat + } + + if params == nil { + params = &ImageExportParams{ + Width: DefaultImageExportWidth, + Height: DefaultImageExportHeight, + } + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(blueprintPath) + len(id) + len(format) + 2) + + endpoint.WriteString(baseURL) + endpoint.WriteString(blueprintPath) + endpoint.WriteString("/" + id) + endpoint.WriteString("/" + format) + + u, err := url.Parse(endpoint.String()) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + u.RawQuery = params.query().Encode() + + req, err := s.client.request(ctx, http.MethodGet, u.String(), http.NoBody) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + return resp.Body, resp, nil +} + +// ExportBudget exports a blueprint's budget in CSV or XLSX format. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#4280d5b3-c9a1-423f-8074-0499447dd8d6 +func (s *BlueprintService) ExportBudget( + ctx context.Context, + id string, + format string, + params *BudgetExportParams, +) ([]byte, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + if id == "" { + return nil, nil, ErrMissingBlueprintID + } + + if format == "" { + format = DefaultBudgetExportFormat + } + + if params == nil { + params = &BudgetExportParams{ + Currency: DefaultBudgetExportCurrency, + Period: DefaultBudgetExportPeriod, + } + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(blueprintPath) + len(id) + len(format) + 9) + + endpoint.WriteString(baseURL) + endpoint.WriteString(blueprintPath) + endpoint.WriteString("/" + id) + endpoint.WriteString("/budget/" + format) + + u, err := url.Parse(endpoint.String()) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + u.RawQuery = params.query().Encode() + + req, err := s.client.request(ctx, http.MethodGet, u.String(), http.NoBody) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + resp, err := s.client.do(ctx, req) + if err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + return resp.Body, resp, nil +} diff --git a/blueprint_internal_test.go b/blueprint_internal_test.go new file mode 100644 index 0000000..c82ff5a --- /dev/null +++ b/blueprint_internal_test.go @@ -0,0 +1,122 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft + +import ( + "net/url" + "reflect" + "strconv" + "testing" +) + +func TestImageExportParams_query(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + give ImageExportParams + want url.Values + }{ + { + name: "Empty parameters", + want: url.Values{}, + }, + { + name: "All parameters set", + give: ImageExportParams{ + PaperSize: "A4", + Grid: true, + Transparent: true, + Landscape: true, + Scale: 1.5, + Width: 1024, + Height: 768, + }, + want: url.Values{ + "paperSize": []string{"A4"}, + "grid": []string{"true"}, + "transparent": []string{"true"}, + "landscape": []string{"true"}, + "scale": []string{strconv.FormatFloat(1.5, 'f', -1, 32)}, + "width": []string{"1024"}, + "height": []string{"768"}, + }, + }, + { + name: "Only paperSize and transparent", + give: ImageExportParams{ + PaperSize: "A3", + Transparent: true, + }, + want: url.Values{ + "paperSize": []string{"A3"}, + "transparent": []string{"true"}, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.give.query(); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("ImageExportParams.query() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBudgetExportParams_query(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + give BudgetExportParams + want url.Values + }{ + { + name: "Empty parameters", + want: url.Values{}, + }, + { + name: "All parameters set", + give: BudgetExportParams{ + Currency: "USD", + Period: "monthly", + Rate: "standard", + }, + want: url.Values{ + "currency": []string{"USD"}, + "period": []string{"monthly"}, + "rate": []string{"standard"}, + }, + }, + { + name: "Only currency and period", + give: BudgetExportParams{ + Currency: "EUR", + Period: "yearly", + }, + want: url.Values{ + "currency": []string{"EUR"}, + "period": []string{"yearly"}, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.give.query(); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("BudgetExportParams.query() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/blueprint_test.go b/blueprint_test.go new file mode 100644 index 0000000..5dfcf02 --- /dev/null +++ b/blueprint_test.go @@ -0,0 +1,929 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft_test + +import ( + "bytes" + "context" + "image/png" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/DataDog/cloudcraft-go" + "github.com/DataDog/cloudcraft-go/internal/xtesting" +) + +const _testBlueprintDataPath string = "tests/data/blueprint" + +func TestBlueprintService_List(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testBlueprintDataPath, "list-valid.json")) + invalidTestData = xtesting.ReadFile(t, filepath.Join(_testBlueprintDataPath, "generic-invalid.json")) + emptyTestData = xtesting.ReadFile(t, filepath.Join(_testBlueprintDataPath, "list-empty.json")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + want []*cloudcraft.Blueprint + wantErr bool + }{ + { + name: "Valid blueprint data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + want: []*cloudcraft.Blueprint{ + { + ID: "60ec30c9-741f-4acb-b5f5-794934987802", + Name: "Web App Reference Architecture", + Tags: nil, + ReadAccess: nil, + WriteAccess: nil, + CreatedAt: xtesting.ParseTime(t, "2023-04-01T21:02:10.781Z"), + UpdatedAt: xtesting.ParseTime(t, "2023-04-01T21:02:10.781Z"), + CreatorID: "9e52d877-4dab-4aa6-95be-c7ba5d685689", + CurrentVersionID: "60ec30c9-741f-4acb-b5f5-794934987802", + LastUserID: "9e52d877-4dab-4aa6-95be-c7ba5d685689", + }, + }, + wantErr: false, + }, + { + name: "Invalid blueprint data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(invalidTestData) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "Empty blueprint data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(emptyTestData) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(tt.handler) + defer server.Close() + + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.Blueprint.List(tt.context) + if (err != nil) != tt.wantErr { + t.Fatalf("Blueprint.List() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Blueprint.List() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBlueprintService_Get(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testBlueprintDataPath, "get-valid.json")) + invalidTestData = xtesting.ReadFile(t, filepath.Join(_testBlueprintDataPath, "generic-invalid.json")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + id string + want *cloudcraft.Blueprint + wantErr bool + }{ + { + name: "Valid blueprint data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + id: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + want: &cloudcraft.Blueprint{ + ID: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + Name: "Test blueprint", + Tags: &[]string{}, + ReadAccess: nil, + WriteAccess: nil, + CreatedAt: xtesting.ParseTime(t, "2023-11-09T23:19:29.611Z"), + UpdatedAt: xtesting.ParseTime(t, "2023-11-09T23:19:41.018Z"), + CreatorID: "9e52d877-4dab-4aa6-95be-c7ba5d685689", + CustomerID: nil, + Data: &cloudcraft.BlueprintData{ + Grid: "infinite", + Name: "Test blueprint", + Text: []map[string]any{}, + Edges: []map[string]any{}, + Icons: []map[string]any{}, + Nodes: []map[string]any{ + { + "id": "d801fe26-1f73-49a5-bbe9-23c5fb0888e0", + "type": "ec2", + "mapPos": []any{float64(-2), float64(11)}, + "region": "us-east-1", + "platform": "linux", + "transparent": false, + "instanceSize": "large", + "instanceType": "m5", + }, + }, + Theme: &cloudcraft.Theme{ + Base: "light", + }, + Groups: []map[string]any{}, + Images: []map[string]any{}, + Version: 4, + Surfaces: []map[string]any{}, + ShareDocs: false, + Connectors: []map[string]any{}, + Projection: "isometric", + LiveOptions: &cloudcraft.LiveOptions{ + AutoLabel: true, + AutoConnect: true, + ExcludedTypes: []string{ + "ebs", + "dxconnection", + "natgateway", + "internetgateway", + "vpngateway", + "customergateway", + }, + UpdatesEnabled: true, + UpdateAllOnScan: true, + UpdateGroupsOnScan: true, + UpdateNodeOnSelect: true, + }, + DisabledLayers: []string{}, + }, + LastUserID: "9e52d877-4dab-4aa6-95be-c7ba5d685689", + }, + wantErr: false, + }, + { + name: "Invalid blueprint data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(invalidTestData) + }, + context: ctx, + id: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + want: nil, + wantErr: true, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + context: ctx, + id: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + id: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + want: nil, + wantErr: true, + }, + { + name: "Missing ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + id: "", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.Blueprint.Get(tt.context, tt.id) + if (err != nil) != tt.wantErr { + t.Fatalf("Blueprint.Get() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Blueprint.Get() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBlueprintService_Create(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testBlueprintDataPath, "create-valid.json")) + invalidTestData = xtesting.ReadFile(t, filepath.Join(_testBlueprintDataPath, "generic-invalid.json")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + give *cloudcraft.Blueprint + want *cloudcraft.Blueprint + wantErr bool + }{ + { + name: "Valid blueprint data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + + w.Write(validTestData) + }, + context: ctx, + give: &cloudcraft.Blueprint{ + Name: "My new blueprint", + }, + want: &cloudcraft.Blueprint{ + ID: "31c014b0-279a-4662-9fd4-3f104a2c4f84", + Name: "My new blueprint", + CreatorID: "9e52d877-4dab-4aa6-95be-c7ba5d685689", + Tags: nil, + ReadAccess: nil, + WriteAccess: nil, + UpdatedAt: xtesting.ParseTime(t, "2023-11-14T22:00:39.332Z"), + CreatedAt: xtesting.ParseTime(t, "2023-11-14T22:00:39.332Z"), + CustomerID: nil, + Data: &cloudcraft.BlueprintData{ + Name: "My new blueprint", + Surfaces: []map[string]any{}, + Version: 4, + }, + LastUserID: "9e52d877-4dab-4aa6-95be-c7ba5d685689", + }, + wantErr: false, + }, + { + name: "Invalid blueprint data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + + w.Write(invalidTestData) + }, + context: ctx, + give: &cloudcraft.Blueprint{ + Name: "My new blueprint", + }, + want: nil, + wantErr: true, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + context: ctx, + give: &cloudcraft.Blueprint{ + Name: "My new blueprint", + }, + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + give: &cloudcraft.Blueprint{ + Name: "My new blueprint", + }, + want: nil, + wantErr: true, + }, + { + name: "Nil blueprint", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: nil, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.Blueprint.Create(tt.context, tt.give) + if (err != nil) != tt.wantErr { + t.Fatalf("Blueprint.Create() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Blueprint.Create() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBlueprintService_Update(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + give *cloudcraft.Blueprint + giveEtag string + want *cloudcraft.Response + wantErr bool + }{ + { + name: "Valid blueprint data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + context: ctx, + give: &cloudcraft.Blueprint{ + ID: "31c014b0-279a-4662-9fd4-3f104a2c4f84", + Name: "My updated blueprint", + }, + giveEtag: `W/"31c014b0-279a-4662-9fd4-3f104a2c4f84"`, + want: &cloudcraft.Response{ + Header: http.Header{ + "Date": []string{ + time.Now().In(time.UTC).Format(http.TimeFormat), + }, + }, + Body: []uint8{}, + Status: http.StatusNoContent, + }, + wantErr: false, + }, + { + name: "Valid blueprint data without etag", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + context: ctx, + give: &cloudcraft.Blueprint{ + ID: "31c014b0-279a-4662-9fd4-3f104a2c4f84", + Name: "My updated blueprint", + }, + giveEtag: "", + want: &cloudcraft.Response{ + Header: http.Header{ + "Date": []string{ + time.Now().In(time.UTC).Format(http.TimeFormat), + }, + }, + Body: []uint8{}, + Status: http.StatusNoContent, + }, + wantErr: false, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + give: &cloudcraft.Blueprint{ + ID: "31c014b0-279a-4662-9fd4-3f104a2c4f84", + Name: "My updated blueprint", + }, + want: nil, + wantErr: true, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + context: ctx, + give: &cloudcraft.Blueprint{ + ID: "31c014b0-279a-4662-9fd4-3f104a2c4f84", + Name: "My updated blueprint", + }, + want: nil, + wantErr: true, + }, + { + name: "Nil blueprint", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: nil, + want: nil, + wantErr: true, + }, + { + name: "Missing blueprint ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: &cloudcraft.Blueprint{ + Name: "My updated blueprint", + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, err := client.Blueprint.Update(tt.context, tt.give, tt.giveEtag) + if (err != nil) != tt.wantErr { + t.Fatalf("Blueprint.Update() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Blueprint.Update() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBlueprintService_Delete(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + give string + want *cloudcraft.Response + wantErr bool + }{ + { + name: "Valid blueprint data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + context: ctx, + give: "31c014b0-279a-4662-9fd4-3f104a2c4f84", + want: &cloudcraft.Response{ + Header: http.Header{ + "Date": []string{ + time.Now().In(time.UTC).Format(http.TimeFormat), + }, + }, + Body: []uint8{}, + Status: http.StatusNoContent, + }, + wantErr: false, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + context: ctx, + give: "31c014b0-279a-4662-9fd4-3f104a2c4f84", + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + give: "31c014b0-279a-4662-9fd4-3f104a2c4f84", + want: nil, + wantErr: true, + }, + { + name: "Missing blueprint ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + give: "", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, err := client.Blueprint.Delete(tt.context, tt.give) + if (err != nil) != tt.wantErr { + t.Fatalf("Blueprint.Delete() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Blueprint.Delete() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBlueprintService_ExportImages(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testBlueprintDataPath, "export-image-valid.png")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + giveID string + giveFormat string + giveParams *cloudcraft.ImageExportParams + wantWidth int + wantHeight int + wantErr bool + }{ + { + name: "Valid blueprint export", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + giveFormat: "png", + giveParams: &cloudcraft.ImageExportParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: false, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + context: ctx, + giveID: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + giveFormat: "png", + giveParams: &cloudcraft.ImageExportParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + giveID: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + giveFormat: "png", + giveParams: &cloudcraft.ImageExportParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: true, + }, + { + name: "Nil image params", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + giveFormat: "png", + giveParams: nil, + wantWidth: 1920, + wantHeight: 1080, + wantErr: false, + }, + { + name: "Missing blueprint ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + giveID: "", + giveFormat: "png", + giveParams: &cloudcraft.ImageExportParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: true, + }, + { + name: "Missing image format", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + giveFormat: "", + giveParams: &cloudcraft.ImageExportParams{ + Width: 1920, + Height: 1080, + }, + wantWidth: 1920, + wantHeight: 1080, + wantErr: false, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.Blueprint.ExportImage(tt.context, tt.giveID, tt.giveFormat, tt.giveParams) + if (err != nil) != tt.wantErr { + t.Fatalf("Blueprint.Export() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + gotPNG, err := png.DecodeConfig(bytes.NewReader(got)) + if err != nil { + t.Fatal(err) + } + + if gotPNG.Width != tt.wantWidth { + t.Fatalf("Blueprint.Export() width = %v, want %v", gotPNG.Width, tt.wantWidth) + } + + if gotPNG.Height != tt.wantHeight { + t.Fatalf("Blueprint.Export() height = %v, want %v", gotPNG.Height, tt.wantHeight) + } + + if !bytes.Equal(got, validTestData) { + t.Fatalf("Blueprint.Export() = %v, want %v", got, validTestData) + } + } + }) + } +} + +func TestBlueprintService_ExportBudget(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testBlueprintDataPath, "export-budget-valid.csv")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + giveID string + giveFormat string + giveParams *cloudcraft.BudgetExportParams + wantSize int + wantErr bool + }{ + { + name: "Valid budget data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + giveFormat: "csv", + giveParams: &cloudcraft.BudgetExportParams{ + Currency: "USD", + Period: "month", + Rate: "monthly", + }, + wantSize: 308, + wantErr: false, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + context: ctx, + giveID: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + giveFormat: "csv", + giveParams: &cloudcraft.BudgetExportParams{ + Currency: "USD", + Period: "month", + Rate: "monthly", + }, + wantSize: 0, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + giveID: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + giveFormat: "csv", + giveParams: &cloudcraft.BudgetExportParams{ + Currency: "USD", + Period: "month", + Rate: "monthly", + }, + wantSize: 0, + wantErr: true, + }, + { + name: "Nil budget params", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + giveFormat: "csv", + giveParams: nil, + wantSize: 308, + wantErr: false, + }, + { + name: "Missing blueprint ID", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: ctx, + giveID: "", + giveFormat: "csv", + giveParams: &cloudcraft.BudgetExportParams{ + Currency: "USD", + Period: "month", + Rate: "monthly", + }, + wantSize: 0, + wantErr: true, + }, + { + name: "Missing budget format", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + giveID: "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + giveFormat: "", + giveParams: &cloudcraft.BudgetExportParams{ + Currency: "USD", + Period: "month", + Rate: "monthly", + }, + wantSize: 308, + wantErr: false, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + endpoint, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.Blueprint.ExportBudget(tt.context, tt.giveID, tt.giveFormat, tt.giveParams) + if (err != nil) != tt.wantErr { + t.Fatalf("BlueprintService.ExportBudget() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && tt.wantSize > 0 && len(got) != tt.wantSize { + t.Fatalf("BlueprintService.ExportBudget() length = %v, want %v", len(got), tt.wantSize) + } + + if !tt.wantErr && tt.wantSize > 0 && !bytes.Equal(got, validTestData) { + t.Fatalf("BlueprintService.ExportBudget() data differs from valid test data") + } + }) + } +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..d7cc8fc --- /dev/null +++ b/client.go @@ -0,0 +1,249 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/DataDog/cloudcraft-go/internal/endpoint" + "github.com/DataDog/cloudcraft-go/internal/meta" + "github.com/DataDog/cloudcraft-go/internal/xerrors" + "github.com/DataDog/cloudcraft-go/internal/xhttp" + "golang.org/x/time/rate" +) + +const ( + // ErrInvalidConfig is returned when a Client is created with an invalid + // Config. + ErrInvalidConfig xerrors.Error = "invalid config" + + // ErrRequestFailed is returned when a request to the Cloudcraft API fails + // for unknown reasons. + ErrRequestFailed xerrors.Error = "request failed with status code" +) + +type ( + // Service is a common struct that can be reused instead of allocating a new + // one for each service on the heap. + service struct { + client *Client + } + + // Client is a client for the Cloudcraft API. + Client struct { + // httpClient is the underlying HTTP client used by the API client. + httpClient *http.Client + + // rateLimiter specifies a client-side requests per second limit. + // + // Ultimately, our API enforces this limit on the server side, but this + // is a good way to be a good citizen. + rateLimiter *rate.Limiter + + // cfg specifies the configuration used by the API client. + cfg *Config + + // Cloudcraft API service fields. + Azure *AzureService + AWS *AWSService + Blueprint *BlueprintService + User *UserService + + // common specifies a common service shared by all services. + common service + } +) + +// NewClient returns a new Client given a Config. If Config is nil, NewClient +// will try to look up the configuration from the environment. +func NewClient(cfg *Config) (*Client, error) { + if cfg == nil { + cfg = NewConfigFromEnv() + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidConfig, err) + } + + baseURL, err := endpoint.Parse(cfg.Scheme, cfg.Host, cfg.Port, cfg.Path) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidConfig, err) + } + + cfg.endpoint = baseURL + + if cfg.Timeout <= 0 { + cfg.Timeout = DefaultTimeout + } + + client := &Client{ + httpClient: xhttp.NewClient(cfg.Timeout), + rateLimiter: rate.NewLimiter(rate.Limit(2), 1), // average of 2 req/s + cfg: cfg, + } + + client.common.client = client + client.Azure = (*AzureService)(&client.common) + client.AWS = (*AWSService)(&client.common) + client.Blueprint = (*BlueprintService)(&client.common) + client.User = (*UserService)(&client.common) + + return client, nil +} + +// SnapshotParams represents query parameters used to customize an Azure or AWS +// account snapshot. +type SnapshotParams struct { + PaperSize string + Projection string + Theme string + Filter []string + Exclude []string + Label bool + Autoconnect bool + Grid bool + Transparent bool + Landscape bool + Scale float32 + Width int + Height int +} + +// query builds a query string from fields with non-zero values and returns it +// as url.Values. +func (p *SnapshotParams) query() url.Values { + values := url.Values{} + + if p.PaperSize != "" { + values.Set("paperSize", p.PaperSize) + } + + if p.Projection != "" { + values.Set("projection", p.Projection) + } + + if p.Theme != "" { + values.Set("theme", p.Theme) + } + + if len(p.Filter) > 0 { + values.Set("filter", strings.Join(p.Filter, ",")) + } + + if len(p.Exclude) > 0 { + values.Set("exclude", strings.Join(p.Exclude, ",")) + } + + if p.Label { + values.Set("label", "true") + } + + if p.Autoconnect { + values.Set("autoconnect", "true") + } + + if p.Grid { + values.Set("grid", "true") + } + + if p.Transparent { + values.Set("transparent", "true") + } + + if p.Landscape { + values.Set("landscape", "true") + } + + if p.Scale != 0 { + values.Set("scale", strconv.FormatFloat(float64(p.Scale), 'f', -1, 32)) + } + + if p.Width != 0 { + values.Set("width", strconv.Itoa(p.Width)) + } + + if p.Height != 0 { + values.Set("height", strconv.Itoa(p.Height)) + } + + return values +} + +// Response represents a response from the Cloudcraft API. +type Response struct { + // Header contains the response headers. + Header http.Header + + // Body contains the response body as a byte slice. + Body []byte + + // Status is the HTTP status code of the response. + Status int +} + +// do performs an HTTP request using the underlying HTTP client. +func (c *Client) do(ctx context.Context, req *http.Request) (*Response, error) { + if err := c.rateLimiter.Wait(ctx); err != nil { + return nil, fmt.Errorf("%w", err) + } + + // Use the context from the parameter. + req = req.WithContext(ctx) + + resp, err := c.httpClient.Do(req) + if err != nil { + select { + case <-req.Context().Done(): + return nil, fmt.Errorf("%w", req.Context().Err()) + default: + return nil, fmt.Errorf("%w", err) + } + } + + defer func() { + if err = xhttp.DrainResponseBody(resp); err != nil { + _ = resp.Body.Close() + } + }() + + if resp.StatusCode > http.StatusNoContent { + return nil, fmt.Errorf("%w: %d", ErrRequestFailed, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + return &Response{ + Header: resp.Header, + Body: body, + Status: resp.StatusCode, + }, nil +} + +// request is a convenience function for creating an HTTP request. +func (c *Client) request( + ctx context.Context, + method, uri string, + body io.Reader, +) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, uri, body) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.cfg.Key) + req.Header.Set("User-Agent", meta.UserAgent) + + return req, nil +} diff --git a/client_internal_test.go b/client_internal_test.go new file mode 100644 index 0000000..96f8e5b --- /dev/null +++ b/client_internal_test.go @@ -0,0 +1,312 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strconv" + "testing" + "time" +) + +func TestSnapshotParams_Query(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + give SnapshotParams + want url.Values + }{ + { + name: "Empty parameters", + want: url.Values{}, + }, + { + name: "All parameters set", + give: SnapshotParams{ + PaperSize: "A4", + Projection: "top", + Theme: "dark", + Filter: []string{"instance", "database"}, + Exclude: []string{"network"}, + Label: true, + Autoconnect: true, + Grid: true, + Transparent: true, + Landscape: true, + Scale: 2.0, + Width: 1920, + Height: 1080, + }, + want: url.Values{ + "paperSize": []string{"A4"}, + "projection": []string{"top"}, + "theme": []string{"dark"}, + "filter": []string{"instance,database"}, + "exclude": []string{"network"}, + "label": []string{"true"}, + "autoconnect": []string{"true"}, + "grid": []string{"true"}, + "transparent": []string{"true"}, + "landscape": []string{"true"}, + "scale": []string{strconv.FormatFloat(2.0, 'f', -1, 32)}, + "width": []string{"1920"}, + "height": []string{"1080"}, + }, + }, + { + name: "Only a few parameters set", + give: SnapshotParams{ + Theme: "light", + Landscape: true, + }, + want: url.Values{ + "theme": []string{"light"}, + "landscape": []string{"true"}, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.give.query(); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("SnapshotParams.query() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDo(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + want *Response + wantErr bool + }{ + { + name: "Valid response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write([]byte(`Hello, World!`)) + }, + context: ctx, + want: &Response{ + Header: http.Header{ + "Content-Length": []string{"13"}, + "Content-Type": []string{"text/plain; charset=utf-8"}, + "Date": []string{ + time.Now().In(time.UTC).Format(http.TimeFormat), + }, + }, + Body: []uint8{'H', 'e', 'l', 'l', 'o', ',', ' ', 'W', 'o', 'r', 'l', 'd', '!'}, + Status: http.StatusOK, + }, + wantErr: false, + }, + { + name: "Context timeout", + handler: func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + + w.WriteHeader(http.StatusOK) + + w.Write([]byte(`Delayed response`)) + }, + context: func() context.Context { + ctxWithTimeout, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + + t.Cleanup(cancel) + + return ctxWithTimeout + }(), + wantErr: true, + }, + { + name: "Invalid HTTP status code", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + }, + context: ctx, + wantErr: true, + }, + { + name: "HTTP Client Do error", + handler: func(w http.ResponseWriter, r *http.Request) { + conn, _, _ := w.(http.Hijacker).Hijack() //nolint:forcetypeassert // should be fine for testing + conn.Close() + }, + context: ctx, + wantErr: true, + }, + { + name: "Response Body Read Error", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + // Instead of writing to the response writer, we'll set a custom + // body that fails on reading. + hijacker, ok := w.(http.Hijacker) + if !ok { + t.Fatal("ResponseWriter does not support Hijacker interface") + } + + conn, _, err := hijacker.Hijack() + if err != nil { + t.Fatal("Hijack failed:", err) + } + + _, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 25\r\nContent-Type: text/plain\r\n\r\n")) + conn.Close() + }, + context: ctx, + wantErr: true, + }, + { + name: "Rate Limiter Error", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + context: func() context.Context { + ctxWithCancel, cancel := context.WithCancel(ctx) + + cancel() + + return ctxWithCancel + }(), + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(tt.handler) + defer server.Close() + + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse mock server URL: %v", err) + } + + cfg := &Config{ + Scheme: endpoint.Scheme, + Host: endpoint.Hostname(), + Port: endpoint.Port(), + Path: DefaultPath, + Key: "not-a-real-key-oRbwhd5RTvWsPJ89ZkASHU13qcyd=", + } + + client, err := NewClient(cfg) + if err != nil { + t.Fatalf("failed to create client for mock tests: %v", err) + } + + req, err := client.request(tt.context, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + t.Fatalf("Request() error = %v", err) + } + + got, err := client.do(tt.context, req) + if (err != nil) != tt.wantErr { + t.Fatalf("Do() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.wantErr { + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Do() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRequest(t *testing.T) { + t.Parallel() + + cfg := &Config{ + Scheme: DefaultScheme, + Host: DefaultHost, + Path: DefaultPath, + Key: "not-a-real-key-oRbwhd5RTvWsPJ89ZkASHU13qcyd=", + } + + client, err := NewClient(cfg) + if err != nil { + t.Fatalf("failed to create client for mock tests: %v", err) + } + + tests := []struct { + name string + method string + uri string + want *http.Request + wantErr bool + }{ + { + name: "Valid request", + method: http.MethodGet, + uri: "https://example.com", + want: &http.Request{ + Method: http.MethodGet, + URL: &url.URL{ + Scheme: "https", + Host: "example.com", + }, + }, + wantErr: false, + }, + { + name: "Invalid request", + method: http.MethodGet, + uri: "://example.com", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := client.request(context.Background(), tt.method, tt.uri, http.NoBody) + if (err != nil) != tt.wantErr { + t.Fatalf("Request() error = %v, wantErr %v", err, tt.wantErr) + } + + if got == nil && tt.wantErr { + return + } + + if got.Method != tt.want.Method { + t.Fatalf("Request().Method = %v, want %v", got.Method, tt.want.Method) + } + + if got.URL.String() != tt.want.URL.String() { + t.Fatalf("Request().URL = %v, want %v", got.URL, tt.want.URL) + } + }) + } +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..530fc05 --- /dev/null +++ b/client_test.go @@ -0,0 +1,93 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft_test + +import ( + "errors" + "testing" + + "github.com/DataDog/cloudcraft-go" +) + +func TestNewClient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + give *cloudcraft.Config + want error + }{ + { + name: "Valid configuration", + give: cloudcraft.NewConfig("not-a-real-key-oRbwhd5RTvWsPJ89ZkASHU13qcyd="), + want: nil, + }, + { + name: "Invalid configuration, missing API key", + give: &cloudcraft.Config{ + Scheme: cloudcraft.DefaultScheme, + Host: cloudcraft.DefaultHost, + Port: cloudcraft.DefaultPort, + Path: cloudcraft.DefaultPath, + Timeout: cloudcraft.DefaultTimeout, + }, + want: cloudcraft.ErrMissingKey, + }, + { + name: "Invalid configuration, invalid API key length", + give: cloudcraft.NewConfig("short_key"), + want: cloudcraft.ErrInvalidKey, + }, + { + name: "Invalid configuration, invalid endpoint", + give: &cloudcraft.Config{ + Scheme: "ftp", + Host: cloudcraft.DefaultHost, + Port: cloudcraft.DefaultPort, + Path: cloudcraft.DefaultPath, + Timeout: cloudcraft.DefaultTimeout, + Key: "not-a-real-key-oRbwhd5RTvWsPJ89ZkASHU13qcyd=", + }, + want: cloudcraft.ErrInvalidConfig, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client, err := cloudcraft.NewClient(tt.give) + + if !errors.Is(err, tt.want) { + t.Fatalf("NewClient() error = %v, want %v", err, tt.want) + } + + if tt.want == nil && client == nil { + t.Error("Expected non-nil client, got nil") + } + }) + } +} + +func TestNewClientWithNilConfig(t *testing.T) { //nolint:paralleltest // t.Setenv is not thread-safe + // Setting environment variables required for NewConfigFromEnv. + t.Setenv("CLOUDCRAFT_PROTOCOL", cloudcraft.DefaultScheme) + t.Setenv("CLOUDCRAFT_HOST", cloudcraft.DefaultHost) + t.Setenv("CLOUDCRAFT_PORT", cloudcraft.DefaultPort) + t.Setenv("CLOUDCRAFT_PATH", cloudcraft.DefaultPath) + t.Setenv("CLOUDCRAFT_API_KEY", "not-a-real-key-oRbwhd5RTvWsPJ89ZkASHU13qcyd=") + t.Setenv("CLOUDCRAFT_TIMEOUT", "80s") + + client, err := cloudcraft.NewClient(nil) + if err != nil { + t.Fatalf("Unexpected error for nil config: %v", err) + } + + if client == nil { + t.Error("Expected non-nil client, got nil") + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..e31e7c7 --- /dev/null +++ b/config.go @@ -0,0 +1,165 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft + +import ( + "net/url" + "time" + + "github.com/DataDog/cloudcraft-go/internal/xerrors" + "github.com/DataDog/cloudcraft-go/internal/xos" +) + +const ( + // ErrInvalidEndpoint is returned when the endpoint is not a valid URL. + ErrInvalidEndpoint xerrors.Error = "invalid endpoint" + + // ErrMissingEndpointScheme is returned when a Config is created without a + // scheme for the endpoint. + ErrMissingEndpointScheme xerrors.Error = "missing endpoint scheme" + + // ErrMissingEndpointHost is returned when a Config is created without a + // host for the endpoint. + ErrMissingEndpointHost xerrors.Error = "missing endpoint host" + + // ErrMissingKey is returned when a Config is created without an API key. + ErrMissingKey xerrors.Error = "missing API key" + + // ErrInvalidKey is returned when a Config is created with an invalid API + // key. + ErrInvalidKey xerrors.Error = "invalid API key; length must be 44" +) + +const ( + // DefaultSceme is the default protocol scheme, such as "http" or "https". + DefaultScheme string = "https" + + // DefaultHost is the default host name or IP address of the Cloudcraft API. + DefaultHost string = "api.cloudcraft.co" + + // DefaultPort is the default port number of the Cloudcraft API. + DefaultPort string = "443" + + // DefaultPath is the default path to the Cloudcraft API. + DefaultPath string = "/" + + // DefaultTimeout is the default timeout for requests made by the Cloudcraft + // API client. + DefaultTimeout time.Duration = time.Second * 120 +) + +// Environment variables used to configure the Config struct. +const ( + EnvScheme string = "CLOUDCRAFT_PROTOCOL" + EnvHost string = "CLOUDCRAFT_HOST" + EnvPort string = "CLOUDCRAFT_PORT" + EnvPath string = "CLOUDCRAFT_PATH" + EnvTimeout string = "CLOUDCRAFT_TIMEOUT" + EnvAPIKey string = "CLOUDCRAFT_API_KEY" //nolint:gosec // false positive +) + +// Config holds the basic configuration for the Cloudcraft API. +type Config struct { + // endpoint specifies the base URL of the Cloudcraft API for HTTP requests. + // It is constructed from the Scheme, Host, Port, and Path fields. + endpoint *url.URL + + // Scheme is the protocol scheme, such as "http" or "https", to use when + // calling the API. + // + // If not set, the value of the CLOUDCRAFT_PROTOCOL environment variable is + // used. If the environment variable is not set, the default value is + // "https". + // + // This field is optional. + Scheme string + + // Host is the host name or IP address of the Cloudcraft API. + // + // If not set, the value of the CLOUDCRAFT_HOST environment variable is + // used. If the environment variable is not set, the default value is the + // public instance of Cloudcraft, "api.cloudcraft.co". + // + // This field is optional. + Host string + + // Port is the port number of the Cloudcraft API. + // + // If not set, the value of the CLOUDCRAFT_PORT environment variable is + // used. If the environment variable is not set, the default value is "443". + // + // This field is optional. + Port string + + // Path is the path to the Cloudcraft API. + // + // If not set, the value of the CLOUDCRAFT_PATH environment variable is + // used. If the environment variable is not set, the default value is "/". + // + // This field is optional. + Path string + + // Key is the API key used to authenticate with the Cloudcraft API. + // + // This field is required. [Learn more]. + // + // [Learn more]: https://developers.cloudcraft.co/#authentication + Key string + + // Timeout is the time limit for requests made by the Cloudcraft API client + // to the Cloudcraft API. + // + // If not set, the value of the CLOUDCRAFT_TIMEOUT environment variable is + // used. If the environment variable is not set, the default value is 80 + // seconds. + // + // This field is optional. + Timeout time.Duration +} + +// NewConfig returns a new Config with the given API key. +func NewConfig(key string) *Config { + return &Config{ + Scheme: DefaultScheme, + Host: DefaultHost, + Port: DefaultPort, + Path: DefaultPath, + Key: key, + Timeout: DefaultTimeout, + } +} + +// NewConfigFromEnv returns a new Config from values set in the environment. +func NewConfigFromEnv() *Config { + return &Config{ + Scheme: xos.GetEnv(EnvScheme, DefaultScheme), + Host: xos.GetEnv(EnvHost, DefaultHost), + Port: xos.GetEnv(EnvPort, DefaultPort), + Path: xos.GetEnv(EnvPath, DefaultPath), + Key: xos.GetEnv(EnvAPIKey, ""), + Timeout: xos.GetDurationEnv(EnvTimeout, DefaultTimeout), + } +} + +// Validate checks that the Config is valid. +func (c *Config) Validate() error { + if c.Scheme == "" { + return ErrMissingEndpointScheme + } + + if c.Host == "" { + return ErrMissingEndpointHost + } + + if c.Key == "" { + return ErrMissingKey + } + + if len(c.Key) != 44 { + return ErrInvalidKey + } + + return nil +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..17b0089 --- /dev/null +++ b/config_test.go @@ -0,0 +1,81 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft_test + +import ( + "testing" + "time" + + "github.com/DataDog/cloudcraft-go" +) + +func TestConfig_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + give cloudcraft.Config + wantErr bool + }{ + { + name: "Valid configuration", + give: cloudcraft.Config{ + Scheme: "https", + Host: "api.example.com", + Port: "443", + Path: "/", + Key: "not-a-real-key-oRbwhd5RTvWsPJ89ZkASHU13qcyd=", + Timeout: time.Second * 80, + }, + wantErr: false, + }, + { + name: "Missing scheme", + give: cloudcraft.Config{ + Host: "api.example.com", + Key: "not-a-real-key-oRbwhd5RTvWsPJ89ZkASHU13qcyd=", + }, + wantErr: true, + }, + { + name: "Missing host", + give: cloudcraft.Config{ + Scheme: "https", + Key: "not-a-real-key-oRbwhd5RTvWsPJ89ZkASHU13qcyd=", + }, + wantErr: true, + }, + { + name: "Missing key", + give: cloudcraft.Config{ + Scheme: "https", + Host: "api.example.com", + }, + wantErr: true, + }, + { + name: "Invalid key length", + give: cloudcraft.Config{ + Scheme: "https", + Host: "api.example.com", + Key: "shortkey", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.give.Validate() + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..51842f2 --- /dev/null +++ b/doc.go @@ -0,0 +1,8 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +// Package cloudcraft provides a Go client for [the Cloudcraft API]. +// +// [the Cloudcraft API]: https://developers.cloudcraft.co +package cloudcraft diff --git a/error.go b/error.go new file mode 100644 index 0000000..42b149e --- /dev/null +++ b/error.go @@ -0,0 +1,30 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft + +import "github.com/DataDog/cloudcraft-go/internal/xerrors" + +const ( + // ErrNilContext is returned when a nil context is passed to a function. + ErrNilContext xerrors.Error = "context cannot be nil" + + // ErrNilAccount is returned when a nil account is passed as an argument. + ErrNilAccount xerrors.Error = "account cannot be nil" + + // ErrAccountsKey is returned when the response from the API to a List call + // is not a list of AWS or Azure accounts. + ErrAccountsKey xerrors.Error = "key 'accounts' not found in response" + + // ErrEmptyAccountName is returned when an empty account name is passed as + // an argument. + ErrEmptyAccountName xerrors.Error = "account name cannot be empty" + + // ErrMissingAccountID is returned when an empty account ID is passed as an + // argument. + ErrEmptyAccountID xerrors.Error = "account ID cannot be empty" + + // ErrEmptyRegion is returned when an empty region is passed as an argument. + ErrEmptyRegion xerrors.Error = "region cannot be empty" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9e3e214 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/DataDog/cloudcraft-go + +go 1.21 + +require golang.org/x/time v0.5.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..71cd7c8 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/internal/endpoint/endpoint.go b/internal/endpoint/endpoint.go new file mode 100644 index 0000000..60b1b47 --- /dev/null +++ b/internal/endpoint/endpoint.go @@ -0,0 +1,63 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +// Package endpoint provides a function to parse fragments of an URL into an +// *url.URL. +package endpoint + +import ( + "fmt" + "net/url" + "strings" + + "github.com/DataDog/cloudcraft-go/internal/xerrors" +) + +const ( + _Slash string = "/" + _ColonSlashSlash string = "://" + _Colon string = ":" +) + +const ( + ErrMissingFragment xerrors.Error = "missing scheme or host" + ErrInvalidScheme xerrors.Error = "invalid URL scheme" +) + +// Parse parses fragments of an URL into an *url.URL. +func Parse(scheme, host, port, path string) (*url.URL, error) { + if scheme == "" || host == "" { + return nil, ErrMissingFragment + } + + if scheme != "https" && scheme != "http" { + return nil, fmt.Errorf("%w", ErrInvalidScheme) + } + + if path == "" { + path = _Slash + } + + var builder strings.Builder + + builder.Grow(len(scheme) + len(host) + len(port) + len(path) + 4) + + builder.WriteString(scheme) + builder.WriteString(_ColonSlashSlash) + builder.WriteString(host) + + if port != "" { + builder.WriteString(_Colon) + builder.WriteString(port) + } + + builder.WriteString(path) + + uri, err := url.Parse(builder.String()) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + return uri, nil +} diff --git a/internal/endpoint/endpoint_test.go b/internal/endpoint/endpoint_test.go new file mode 100644 index 0000000..9d091f7 --- /dev/null +++ b/internal/endpoint/endpoint_test.go @@ -0,0 +1,90 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package endpoint_test + +import ( + "net/url" + "testing" + + "github.com/DataDog/cloudcraft-go/internal/endpoint" +) + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scheme string + host string + port string + path string + want *url.URL + wantError bool + }{ + { + name: "Valid URL", + scheme: "https", + host: "example.com", + port: "8080", + path: "", + want: &url.URL{Scheme: "https", Host: "example.com:8080", Path: "/"}, + wantError: false, + }, + { + name: "Invalid URL", + scheme: "https", + host: "example.com", + port: "\n", // Invalid character in port + path: "/test", + want: nil, + wantError: true, + }, + { + name: "Invalid URL scheme", + scheme: "ftp", + host: "example.com", + port: "8080", + path: "/test", + want: nil, + wantError: true, + }, + { + name: "Missing Scheme", + scheme: "", + host: "example.com", + port: "8080", + path: "/test", + want: nil, + wantError: true, + }, + { + name: "Missing Host", + scheme: "https", + host: "", + port: "8080", + path: "/test", + want: nil, + wantError: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := endpoint.Parse(tt.scheme, tt.host, tt.port, tt.path) + + if (err != nil) != tt.wantError { + t.Errorf("Expected error? %v, got: %v", tt.wantError, err) + } + + if tt.want != nil && got.String() != tt.want.String() { + t.Errorf("Expected URL: %v, got: %v", tt.want, got) + } + }) + } +} diff --git a/internal/meta/meta.go b/internal/meta/meta.go new file mode 100644 index 0000000..1cae673 --- /dev/null +++ b/internal/meta/meta.go @@ -0,0 +1,17 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +// Package meta provides build information about the cloudcraft module. +package meta + +const ( + // Name is the name of the package. + Name string = "cloudcraft-go" + + // Version is the version of the package. + Version string = "1.0.0" + + // UserAgent is the user agent string for the package. + UserAgent string = Name + "/" + Version +) diff --git a/internal/xerrors/xerrors.go b/internal/xerrors/xerrors.go new file mode 100644 index 0000000..3dca2ee --- /dev/null +++ b/internal/xerrors/xerrors.go @@ -0,0 +1,16 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +// Package xerrors provides helper functions and types for error handling. +package xerrors + +// Error is an [imuutable error] type. +// +// [imuutable error]: https://dave.cheney.net/2016/04/07/constant-errors +type Error string + +// Error implements the error interface for Error. +func (e Error) Error() string { + return string(e) +} diff --git a/internal/xerrors/xerrors_test.go b/internal/xerrors/xerrors_test.go new file mode 100644 index 0000000..7d8ab2e --- /dev/null +++ b/internal/xerrors/xerrors_test.go @@ -0,0 +1,46 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package xerrors_test + +import ( + "testing" + + "github.com/DataDog/cloudcraft-go/internal/xerrors" +) + +func TestError_ErrorMethod(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputError xerrors.Error + expectedError string + }{ + { + name: "Empty error", + inputError: "", + expectedError: "", + }, + { + name: "Simple error message", + inputError: "something went wrong", + expectedError: "something went wrong", + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + actualError := tt.inputError.Error() + + if actualError != tt.expectedError { + t.Fatalf("Expected error: %q, got: %q", tt.expectedError, actualError) + } + }) + } +} diff --git a/internal/xhttp/client.go b/internal/xhttp/client.go new file mode 100644 index 0000000..31371fa --- /dev/null +++ b/internal/xhttp/client.go @@ -0,0 +1,47 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package xhttp + +import ( + "crypto/tls" + "net/http" + "time" +) + +const ( + // DefaultMaxIddleConns is the default maximum number of idle connections in + // the pool. + DefaultMaxIddleConns int = 100 + + // DefaultMaxIddleConnsPerHost is the default maximum number of idle connections in + // the pool per host. + DefaultMaxIddleConnsPerHost int = 10 + + // DefaultLRUClientSessionCacheCapacity is the default capacity of the LRU client session cache. + DefaultLRUClientSessionCacheCapacity int = 64 +) + +// NewClient creates a new HTTP client with sane defaults given the provided +// timeout. +func NewClient(timeout time.Duration) *http.Client { + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS13, + SessionTicketsDisabled: false, + ClientSessionCache: tls.NewLRUClientSessionCache(DefaultLRUClientSessionCacheCapacity), + }, + MaxIdleConns: DefaultMaxIddleConns, + MaxIdleConnsPerHost: DefaultMaxIddleConnsPerHost, + DisableCompression: true, + ForceAttemptHTTP2: true, + }, + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} diff --git a/internal/xhttp/client_test.go b/internal/xhttp/client_test.go new file mode 100644 index 0000000..9dd0284 --- /dev/null +++ b/internal/xhttp/client_test.go @@ -0,0 +1,77 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package xhttp_test + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/DataDog/cloudcraft-go/internal/xhttp" +) + +func TestNewClient(t *testing.T) { + t.Parallel() + + var ( + testTimeout = 30 * time.Second + client = xhttp.NewClient(testTimeout) + ) + + if client == nil { + t.Fatal("NewClient returned nil, expected *http.Client") + } + + if client.Timeout != testTimeout { + t.Errorf("Expected timeout %v, got %v", testTimeout, client.Timeout) + } + + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatal("Transport is not of type *http.Transport") + } + + if transport.TLSClientConfig.MinVersion != tls.VersionTLS13 { + t.Errorf("Expected TLS min version %v, got %v", tls.VersionTLS12, transport.TLSClientConfig.MinVersion) + } + + if transport.MaxIdleConns != xhttp.DefaultMaxIddleConns { + t.Errorf("Expected MaxIdleConns %d, got %d", xhttp.DefaultMaxIddleConns, transport.MaxIdleConns) + } + + if transport.MaxIdleConnsPerHost != xhttp.DefaultMaxIddleConnsPerHost { + t.Errorf("Expected MaxIdleConnsPerHost %d, got %d", xhttp.DefaultMaxIddleConnsPerHost, transport.MaxIdleConnsPerHost) + } + + if !transport.ForceAttemptHTTP2 { + t.Error("Expected ForceAttemptHTTP2 to be true") + } +} + +func TestNewClient_CheckRedirect(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://example.com", http.StatusFound) + })) + defer ts.Close() + + var ( + testTimeout = 30 * time.Second + client = xhttp.NewClient(testTimeout) + ) + + resp, err := client.Get(ts.URL) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + t.Fatalf("Expected status code %d, got %d", http.StatusFound, resp.StatusCode) + } +} diff --git a/internal/xhttp/response.go b/internal/xhttp/response.go new file mode 100644 index 0000000..e098518 --- /dev/null +++ b/internal/xhttp/response.go @@ -0,0 +1,37 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package xhttp + +import ( + "fmt" + "io" + "net/http" + + "github.com/DataDog/cloudcraft-go/internal/xerrors" +) + +const ( + // ErrCannotDrainResponse is returned when a response body cannot be drained. + ErrCannotDrainResponse xerrors.Error = "cannot drain response body" + + // ErrCannotCloseResponse is returned when a response body cannot be closed. + ErrCannotCloseResponse xerrors.Error = "cannot close response body" +) + +// DrainResponseBody reads and discards the remaining content of the response +// body until EOF, then closes it. If an error occurs while draining or closing +// the response body, an error is returned. +func DrainResponseBody(resp *http.Response) error { + _, err := io.Copy(io.Discard, resp.Body) + if err != nil { + return fmt.Errorf("%w: %w", ErrCannotDrainResponse, err) + } + + if err = resp.Body.Close(); err != nil { + return fmt.Errorf("%w: %w", ErrCannotCloseResponse, err) + } + + return nil +} diff --git a/internal/xhttp/response_test.go b/internal/xhttp/response_test.go new file mode 100644 index 0000000..237ec98 --- /dev/null +++ b/internal/xhttp/response_test.go @@ -0,0 +1,103 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package xhttp_test + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "testing" + + "github.com/DataDog/cloudcraft-go/internal/xhttp" +) + +type errReader struct{} + +func (*errReader) Read(_ []byte) (n int, err error) { + return 0, io.ErrUnexpectedEOF +} + +type customReadCloser struct { + data *bytes.Buffer +} + +func (c *customReadCloser) Read(p []byte) (n int, err error) { + return c.data.Read(p) +} + +func (*customReadCloser) Close() error { + return errors.New("mock close error") +} + +func TestDrainResponseBody(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resp *http.Response + wantErr bool + }{ + { + name: "valid response body", + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte("valid response body"))), + }, + wantErr: false, + }, + { + name: "empty response body", + resp: &http.Response{ + Body: io.NopCloser(bytes.NewReader([]byte(""))), + }, + wantErr: false, + }, + { + name: "error response body", + resp: &http.Response{ + Body: io.NopCloser(&errReader{}), + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := xhttp.DrainResponseBody(tt.resp) + + if tt.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestDrainResponseBody_ErrorClose(t *testing.T) { + t.Parallel() + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: &customReadCloser{data: bytes.NewBufferString("test")}, + } + + err := xhttp.DrainResponseBody(resp) + if err == nil { + t.Error("expected error, got nil") + } + + want := fmt.Errorf("%w: %w", xhttp.ErrCannotCloseResponse, errors.New("mock close error")) + if err.Error() != want.Error() { + t.Errorf("got: %v, want: %v", err, want) + } +} diff --git a/internal/xhttp/xhttp.go b/internal/xhttp/xhttp.go new file mode 100644 index 0000000..78d5c34 --- /dev/null +++ b/internal/xhttp/xhttp.go @@ -0,0 +1,6 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +// Package xhttp provides utilities and functions for working with HTTP. +package xhttp diff --git a/internal/xos/env.go b/internal/xos/env.go new file mode 100644 index 0000000..a7bb3c0 --- /dev/null +++ b/internal/xos/env.go @@ -0,0 +1,42 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package xos + +import ( + "os" + "time" +) + +// GetEnv returns the string value of the environment variable named by the key. +// +// If the variable is present in the environment the value (which may be empty) +// or if the variable is unset, a fallback value is returned. +func GetEnv(key, fallback string) string { + value, found := os.LookupEnv(key) + if !found || value == "" { + return fallback + } + + return value +} + +// GetDurationEnv returns the time.Duration value of the environment variable +// named by the key. +// +// If the variable is present in the environment the value (which may be empty) +// or if the variable is unset, a fallback value is returned. +func GetDurationEnv(key string, fallback time.Duration) time.Duration { + value, found := os.LookupEnv(key) + if !found || value == "" { + return fallback + } + + durationValue, err := time.ParseDuration(value) + if err != nil { + return fallback + } + + return durationValue +} diff --git a/internal/xos/env_test.go b/internal/xos/env_test.go new file mode 100644 index 0000000..0ab63e3 --- /dev/null +++ b/internal/xos/env_test.go @@ -0,0 +1,103 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package xos_test + +import ( + "os" + "testing" + "time" + + "github.com/DataDog/cloudcraft-go/internal/xos" +) + +func TestGetEnv(t *testing.T) { //nolint:paralleltest // Test is not safe to run in parallel. + tests := []struct { + name string + key string + fallback string + envVal string + want string + }{ + { + name: "non-existent variable with a fallback", + key: "SOMETHING_THAT_DOES_NOT_EXIST", + fallback: "fallback", + envVal: "", + want: "fallback", + }, + { + name: "existent variable with a value", + key: "SOME_EXISTENT_VARIABLE", + fallback: "fallback", + envVal: "test-value", + want: "test-value", + }, + { + name: "existent variable with an empty value", + key: "SOME_EXISTENT_VARIABLE", + fallback: "fallback", + envVal: "", + want: "fallback", + }, + } + + for _, tt := range tests { //nolint:paralleltest // See above. + t.Run(tt.name, func(t *testing.T) { + t.Setenv(tt.key, tt.envVal) + defer os.Unsetenv(tt.key) + + got := xos.GetEnv(tt.key, tt.fallback) + + if got != tt.want { + t.Errorf("GetEnv() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetDurationEnv(t *testing.T) { //nolint:paralleltest // See above. + tests := []struct { + name string + key string + fallback time.Duration + envVal string + want time.Duration + }{ + { + name: "non-existent variable with a fallback", + key: "SOMETHING_THAT_DOES_NOT_EXIST", + fallback: 5 * time.Minute, + envVal: "", + want: 5 * time.Minute, + }, + { + name: "existent variable with a valid duration value", + key: "SOME_EXISTENT_VARIABLE", + fallback: 5 * time.Minute, + envVal: "1h30m", + want: 1*time.Hour + 30*time.Minute, + }, + { + name: "existent variable with an invalid duration value", + key: "SOME_EXISTENT_VARIABLE", + fallback: 5 * time.Minute, + envVal: "not-a-duration", + want: 5 * time.Minute, + }, + } + + for _, tt := range tests { //nolint:paralleltest // See above. + t.Run(tt.name, func(t *testing.T) { + t.Setenv(tt.key, tt.envVal) + defer os.Unsetenv(tt.key) + + got := xos.GetDurationEnv(tt.key, tt.fallback) + + if got != tt.want { + t.Errorf("GetDurationEnv() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/xos/xos.go b/internal/xos/xos.go new file mode 100644 index 0000000..32da357 --- /dev/null +++ b/internal/xos/xos.go @@ -0,0 +1,6 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +// Package xos provides extensions for Go's standard os package. +package xos diff --git a/internal/xtesting/setup.go b/internal/xtesting/setup.go new file mode 100644 index 0000000..d70c551 --- /dev/null +++ b/internal/xtesting/setup.go @@ -0,0 +1,63 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package xtesting + +import ( + "net/url" + "testing" + + "github.com/DataDog/cloudcraft-go" +) + +const _envAPIKey string = "CLOUDCRAFT_TEST_API_KEY" + +// SetupMockClient sets up a test API client for unit tests against a mock +// version of the Cloudcraft API. +func SetupMockClient(t *testing.T, endpoint *url.URL) *cloudcraft.Client { + t.Helper() + + cfg := &cloudcraft.Config{ + Scheme: endpoint.Scheme, + Host: endpoint.Hostname(), + Port: endpoint.Port(), + Path: cloudcraft.DefaultPath, + Key: "not-a-real-key-oRbwhd5RTvWsPJ89ZkASHU13qcyd=", + } + + client, err := cloudcraft.NewClient(cfg) + if err != nil { + t.Fatalf("failed to create client for mock tests: %v", err) + } + + return client +} + +// SetupLiveClient sets up a test API client for unit tests against the live +// Cloudcraft API. +// +// The following environment variables are required: +// - CLOUDCRAFT_TEST_API_KEY +// +// If any of these variables are not set, SetupLiveClient will fail the test. +func SetupLiveClient(t *testing.T) *cloudcraft.Client { + t.Helper() + + key := GetEnv(t, _envAPIKey) + + cfg := &cloudcraft.Config{ + Scheme: cloudcraft.DefaultScheme, + Host: cloudcraft.DefaultHost, + Port: cloudcraft.DefaultPort, + Path: cloudcraft.DefaultPath, + Key: key, + } + + client, err := cloudcraft.NewClient(cfg) + if err != nil { + t.Fatalf("failed to create client for live tests: %v", err) + } + + return client +} diff --git a/internal/xtesting/xtesting.go b/internal/xtesting/xtesting.go new file mode 100644 index 0000000..0de975b --- /dev/null +++ b/internal/xtesting/xtesting.go @@ -0,0 +1,94 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +// Package xtesting provides functions and utilities for testing the Cloudcraft +// SDK. +package xtesting + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "os" + "testing" + "time" + + "github.com/DataDog/cloudcraft-go/internal/xerrors" +) + +// ErrGreaterThanZero is returned when a given value is not greater than zero. +const ErrGreaterThanZero xerrors.Error = "value must be greater than zero" + +// RandomString returns a random string of length n that is safe for use in a +// URL. +func RandomString(t *testing.T, r io.Reader, n int) (string, error) { + t.Helper() + + if n <= 0 { + return "", fmt.Errorf("%w: %d", ErrGreaterThanZero, n) + } + + b := make([]byte, n) //nolint:makezero // no need for this specific use case + + _, err := io.ReadFull(r, b) + if err != nil { + return "", fmt.Errorf("%w", err) + } + + encoded := base64.URLEncoding.EncodeToString(b) + + return encoded[:n], nil +} + +// UniqueName returns an unique name that can be used as a resource name in +// Cloudcraft. +func UniqueName(t *testing.T) string { + t.Helper() + + suffix, err := RandomString(t, rand.Reader, 16) + if err != nil { + t.Fatalf("failed to generate random string for unique name: %v", err) + } + + return fmt.Sprintf("Go SDK Test (%s)", suffix) +} + +// ReadFile reads the named file and returns its contents. +func ReadFile(t *testing.T, name string) []byte { + t.Helper() + + file, err := os.ReadFile(name) + if err != nil { + t.Fatalf("failed to read test data file %q: %v", name, err) + } + + return file +} + +// GetEnv returns the value of the environment variable with the given name or +// fails the test if the variable is not set. +func GetEnv(t *testing.T, name string) string { + t.Helper() + + value, found := os.LookupEnv(name) + if !found { + t.Fatalf("environment variable %q is not set; please set it before running the tests", name) + } + + return value +} + +// ParseTime returns the time parsed from the given string or fails the test if +// the string is not a valid time. +func ParseTime(t *testing.T, str string) time.Time { + t.Helper() + + parsedTime, err := time.Parse(time.RFC3339, str) + if err != nil { + t.Fatalf("failed to parse time: %v", err) + } + + return parsedTime +} diff --git a/internal/xtesting/xtesting_test.go b/internal/xtesting/xtesting_test.go new file mode 100644 index 0000000..1c057c3 --- /dev/null +++ b/internal/xtesting/xtesting_test.go @@ -0,0 +1,102 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package xtesting_test + +import ( + "crypto/rand" + "errors" + "io" + "strings" + "testing" + + "github.com/DataDog/cloudcraft-go/internal/xtesting" +) + +// ErrForced is a mock error used for testing. +var ErrForced = errors.New("forced error") + +type errorRandReader struct{} + +func (errorRandReader) Read(_ []byte) (_ int, _ error) { + return 0, ErrForced +} + +func TestRandomString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + give int + giveRand io.Reader + want int + wantErr error + }{ + { + name: "PositiveLength", + give: 10, + giveRand: rand.Reader, + want: 10, + }, + { + name: "ZeroLength", + give: 0, + giveRand: rand.Reader, + want: 0, + wantErr: xtesting.ErrGreaterThanZero, + }, + { + name: "NegativeLength", + give: -5, + giveRand: rand.Reader, + want: 0, + wantErr: xtesting.ErrGreaterThanZero, + }, + { + name: "RandomReadError", + give: 10, + giveRand: errorRandReader{}, + want: 0, + wantErr: ErrForced, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + output, err := xtesting.RandomString(t, tt.giveRand, tt.give) + if (err != nil) != (tt.wantErr != nil) { + t.Fatalf("RandomString() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil && !errors.Is(err, tt.wantErr) { + t.Fatalf("RandomString() error = %v, wantErr %v", err, tt.wantErr) + } + + if len(output) != tt.want { + t.Fatalf("RandomString() length = %d, want %d", len(output), tt.want) + } + }) + } +} + +func TestUniqueName(t *testing.T) { + t.Parallel() + + got := xtesting.UniqueName(t) + + if !strings.HasPrefix(got, "Go SDK Test (") || !strings.HasSuffix(got, ")") { + t.Fatalf("UniqueName() output format is incorrect, got: %s", got) + } + + // Checking if the length of the unique part (random string) is 16 + // characters. Since the prefix is "Go SDK Test (" and suffix is ")", the + // unique part starts at 13th character and ends before the last character. + if len(got) <= 14 || len(got)-14 != 16 { + t.Fatalf("UniqueName() output does not have the expected length, got: %s", got) + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..dd16f81 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,20 @@ +# Integration tests + +This repository contains tests against the live [Cloudcraft API](https://developers.cloudcraft.co/) and test data to be used by mock tests. + +You can run integration tests manually by creating an `.env` file with the required environment variables and invoking: + +```bash +make test/integration +``` + +The `.env` should be located at [../](../) and look like this: + +``` +CLOUDCRAFT_TEST_API_KEY=XXXX +CLOUDCRAFT_TEST_AWS_ROLE_ARN=XXXX +CLOUDCRAFT_TEST_AZURE_APPLICATION_ID=XXXX +CLOUDCRAFT_TEST_AZURE_DIRECTORY_ID=XXXX +CLOUDCRAFT_TEST_AZURE_SUBSCRIPTION_ID=XXXX +CLOUDCRAFT_TEST_AZURE_CLIENT_SECRET=XXXX +``` diff --git a/tests/data/aws/create-valid.json b/tests/data/aws/create-valid.json new file mode 100644 index 0000000..4080b14 --- /dev/null +++ b/tests/data/aws/create-valid.json @@ -0,0 +1,11 @@ +{ + "id": "fe3e5b29-a0e8-41ca-91e2-02a0441b1d33", + "name": "Go SDK Test", + "roleArn": "arn:aws:iam::558791803304:role/cloudcraft", + "externalId": "8a8a745a-d01f-4541-8ab0-e3558e7c6b1c", + "readAccess": null, + "writeAccess": null, + "createdAt": "2019-02-19T16:20:34.042Z", + "updatedAt": "2022-08-05T18:13:05.625Z", + "CreatorId": "17d5fe91-9efb-4b1a-90cd-0b885b1d43b9" +} diff --git a/tests/data/aws/generic-invalid.json b/tests/data/aws/generic-invalid.json new file mode 100644 index 0000000..9363f65 --- /dev/null +++ b/tests/data/aws/generic-invalid.json @@ -0,0 +1,20 @@ + "accounts": [ + { + "id": "47830a91-51b7-4708-b9b2-5f3d121fc039", + "name": "Go SDK Test", + "roleArn": "arn:aws:iam::643880554691j:role/cloudcraft", + "externalId": "61fc01d6-3e6f-47ab-bc44-53fab97c217a", + "readAccess": [ + "team/5f209338-50a1-495f-90dd-73251dec7329", + "team/d7cd0211-85a7-45fc-9292-8d5c62cef70a" + ], + "writeAccess": [ + "team/5f209338-50a1-495f-90dd-73251dec7329", + "team/d7cd0211-85a7-45fc-9292-8d5c62cef70a" + ], + "createdAt": "2019-02-19T16:20:34.042Z", + "updatedAt": "2022-08-05T18:13:05.625Z", + "CreatorId": "280ccb78-6a06-4e28-adad-8d16d413be50" + } + ] +} diff --git a/tests/data/aws/iam-parameters-valid.json b/tests/data/aws/iam-parameters-valid.json new file mode 100644 index 0000000..5b5bb14 --- /dev/null +++ b/tests/data/aws/iam-parameters-valid.json @@ -0,0 +1,5 @@ +{ + "accountId": "912185983511", + "externalId": "4414aef4-8f04-4b0b-8425-d73b84dcaa2d", + "awsConsoleUrl": "https://console.aws.amazon.com/iam/home?#/roles$new?step=type&roleType=crossAccount&isThirdParty&accountID=912185983511&externalID=4414aef4-8f04-4b0b-8425-d73b84dcaa2d&roleName=cloudcraft&policies=arn:aws:iam::aws:policy%2FReadOnlyAccess" +} diff --git a/tests/data/aws/iam-policy-valid.json b/tests/data/aws/iam-policy-valid.json new file mode 100644 index 0000000..d6f359c --- /dev/null +++ b/tests/data/aws/iam-policy-valid.json @@ -0,0 +1,23 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "apigateway:GET", + "Resource": [ + "arn:aws:apigateway:*::/apis", + "arn:aws:apigateway:*::/apis/*", + "..." + ] + }, + { + "Effect": "Allow", + "Action": [ + "autoscaling:DescribeAutoScalingGroups", + "cassandra:Select", + "..." + ], + "Resource": "*" + } + ] +} diff --git a/tests/data/aws/list-empty.json b/tests/data/aws/list-empty.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/data/aws/list-empty.json @@ -0,0 +1 @@ +{} diff --git a/tests/data/aws/list-valid.json b/tests/data/aws/list-valid.json new file mode 100644 index 0000000..065d5f9 --- /dev/null +++ b/tests/data/aws/list-valid.json @@ -0,0 +1,22 @@ +{ + "accounts": [ + { + "id": "47830a91-51b7-4708-b9b2-5f3d121fc039", + "name": "Go SDK Test", + "roleArn": "arn:aws:iam::558791803304:role/cloudcraft", + "externalId": "61fc01d6-3e6f-47ab-bc44-53fab97c217a", + "readAccess": [ + "team/5f209338-50a1-495f-90dd-73251dec7329", + "team/d7cd0211-85a7-45fc-9292-8d5c62cef70a" + ], + "writeAccess": [ + "team/5f209338-50a1-495f-90dd-73251dec7329", + "team/d7cd0211-85a7-45fc-9292-8d5c62cef70a" + ], + "createdAt": "2019-02-19T16:20:34.042Z", + "updatedAt": "2022-08-05T18:13:05.625Z", + "CreatorId": "280ccb78-6a06-4e28-adad-8d16d413be50", + "source": "aws" + } + ] +} diff --git a/tests/data/aws/snapshot-valid.png b/tests/data/aws/snapshot-valid.png new file mode 100644 index 0000000..5554aad Binary files /dev/null and b/tests/data/aws/snapshot-valid.png differ diff --git a/tests/data/azure/create-valid.json b/tests/data/azure/create-valid.json new file mode 100644 index 0000000..3f074bd --- /dev/null +++ b/tests/data/azure/create-valid.json @@ -0,0 +1,14 @@ +{ + "clientSecret": "tV>0}(,[g91|V5mV|:>~rC841E7}[~n9~Wt4;H%II4", + "id": "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + "name": "Go SDK Test", + "applicationId": "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + "directoryId": "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + "subscriptionId": "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + "readAccess": null, + "writeAccess": null, + "CreatorId": "6935c7da-cdfb-4885-902c-25aa00720ab4", + "updatedAt": "2023-11-20T22:11:43.688Z", + "createdAt": "2023-11-20T22:11:43.688Z", + "CustomerId": null +} diff --git a/tests/data/azure/generic-invalid.json b/tests/data/azure/generic-invalid.json new file mode 100644 index 0000000..fec0adf --- /dev/null +++ b/tests/data/azure/generic-invalid.json @@ -0,0 +1,17 @@ + "accounts": [ + { + "id": "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + "name": "Go SDK Test", + "applicationId": "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + "directoryId": "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + "subscriptionId": "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + "readAccess": [], + "writeAccess": [], + "createdAt": "2023-03-15T20:42:52.704Z", + "updatedAt": "2023-03-15T20:43:10.171Z", + "CreatorId": "6935c7da-cdfb-4885-902c-25aa00720ab4", + "hint": "3RK", + "source": "azure" + } + ] +} diff --git a/tests/data/azure/list-empty.json b/tests/data/azure/list-empty.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/data/azure/list-empty.json @@ -0,0 +1 @@ +{} diff --git a/tests/data/azure/list-valid.json b/tests/data/azure/list-valid.json new file mode 100644 index 0000000..cd71ffb --- /dev/null +++ b/tests/data/azure/list-valid.json @@ -0,0 +1,18 @@ +{ + "accounts": [ + { + "id": "4349ccdb-a2fd-4a89-a07b-48e3e330670b", + "name": "Go SDK Test", + "applicationId": "3a64bc23-5dd6-4624-8ce8-fe3e61b41579", + "directoryId": "5d7ef62e-c8bb-41fc-9a55-9a2c30701027", + "subscriptionId": "db0297eb-ad6c-4e63-86b0-c1acb6a16570", + "readAccess": [], + "writeAccess": [], + "createdAt": "2023-03-15T20:42:52.704Z", + "updatedAt": "2023-03-15T20:43:10.171Z", + "CreatorId": "6935c7da-cdfb-4885-902c-25aa00720ab4", + "hint": "3RK", + "source": "azure" + } + ] +} diff --git a/tests/data/azure/snapshot-valid.png b/tests/data/azure/snapshot-valid.png new file mode 100644 index 0000000..5554aad Binary files /dev/null and b/tests/data/azure/snapshot-valid.png differ diff --git a/tests/data/blueprint/create-valid.json b/tests/data/blueprint/create-valid.json new file mode 100644 index 0000000..e713495 --- /dev/null +++ b/tests/data/blueprint/create-valid.json @@ -0,0 +1,17 @@ +{ + "id": "31c014b0-279a-4662-9fd4-3f104a2c4f84", + "name": "My new blueprint", + "CreatorId": "9e52d877-4dab-4aa6-95be-c7ba5d685689", + "tags": null, + "readAccess": null, + "writeAccess": null, + "updatedAt": "2023-11-14T22:00:39.332Z", + "createdAt": "2023-11-14T22:00:39.332Z", + "CustomerId": null, + "data": { + "name": "My new blueprint", + "surfaces": [], + "version": 4 + }, + "LastUserId": "9e52d877-4dab-4aa6-95be-c7ba5d685689" +} diff --git a/tests/data/blueprint/export-budget-valid.csv b/tests/data/blueprint/export-budget-valid.csv new file mode 100644 index 0000000..ebb77a9 --- /dev/null +++ b/tests/data/blueprint/export-budget-valid.csv @@ -0,0 +1 @@ +category,type,region,count,unitPrice,cost,instanceType,instanceSize,platform,role,engine,storage,dataGb,snapshots,volume,iops,memory,mRequests,computeDuration,readUnits,writeUnits,readConsistency,shards,putUnits,extendedRetention,emailsOut,requests,notifications,notificationType,cache,apiCalls,tier,instance \ No newline at end of file diff --git a/tests/data/blueprint/export-image-valid.png b/tests/data/blueprint/export-image-valid.png new file mode 100644 index 0000000..5554aad Binary files /dev/null and b/tests/data/blueprint/export-image-valid.png differ diff --git a/tests/data/blueprint/generic-invalid.json b/tests/data/blueprint/generic-invalid.json new file mode 100644 index 0000000..2652bae --- /dev/null +++ b/tests/data/blueprint/generic-invalid.json @@ -0,0 +1,34 @@ +{ + "blueprints": [ + { + "id": "b8998090-1879-40de-bd64-48af684fcde7", + "name": "TARDIS Achitecture", + "tags": [], + "readAccess": [ + "team/455c1fd4-3384-4f4c-834b-66ff7d5f4a31" + ], + "writeAccess": [ + "team/455c1fd4-3384-4f4c-834b-66ff7d5f4a31" + ], + "createdAt": "2022-06-16T22:24:38.256Z", + "updatedAt": "2022-09-29T18:26:36.848Z", + "CreatorId": "151cb27f-2cc2-4d16-aca5-dc6452a8e3b9", + "LastUserId": "151cb27f-2cc2-4d16-aca5-dc6452a8e3b9" + }, + { + "id": "fbeb3cc5-0324-43c4-ae2d-0bc419f3530a", + "name": "Generic blueprint for tests", + "tags": [ + "tests", + "AWS", + "us-east-1", + "Cloudcraft (Documentation)", + "Cloudcraft (SDKs)" + ], + "readAccess": null, + "writeAccess": null, + "createdAt": "2022-06-30T16:00:42.637Z", + "updatedAt": "2022-09-23T04:21:24.294Z", + "CreatorId": "a1ca7051-41de-417b-b1a1-47b16efd1af1", + "LastUserId": "a1ca7051-41de-417b-b1a1-47b16efd1af1" + } diff --git a/tests/data/blueprint/get-valid.json b/tests/data/blueprint/get-valid.json new file mode 100644 index 0000000..756ed28 --- /dev/null +++ b/tests/data/blueprint/get-valid.json @@ -0,0 +1,61 @@ +{ + "id": "0f1a4e20-a887-4467-a37b-1bc7a3deb9a9", + "name": "Test blueprint", + "tags": [], + "readAccess": null, + "writeAccess": null, + "createdAt": "2023-11-09T23:19:29.611Z", + "updatedAt": "2023-11-09T23:19:41.018Z", + "CreatorId": "9e52d877-4dab-4aa6-95be-c7ba5d685689", + "CustomerId": null, + "data": { + "grid": "infinite", + "name": "Test blueprint", + "text": [], + "edges": [], + "icons": [], + "nodes": [ + { + "id": "d801fe26-1f73-49a5-bbe9-23c5fb0888e0", + "type": "ec2", + "mapPos": [ + -2, + 11 + ], + "region": "us-east-1", + "platform": "linux", + "transparent": false, + "instanceSize": "large", + "instanceType": "m5" + } + ], + "theme": { + "base": "light" + }, + "groups": [], + "images": [], + "version": 4, + "surfaces": [], + "shareDocs": false, + "connectors": [], + "projection": "isometric", + "liveOptions": { + "autoLabel": true, + "autoConnect": true, + "excludedTypes": [ + "ebs", + "dxconnection", + "natgateway", + "internetgateway", + "vpngateway", + "customergateway" + ], + "updatesEnabled": true, + "updateAllOnScan": true, + "updateGroupsOnScan": true, + "updateNodeOnSelect": true + }, + "disabledLayers": [] + }, + "LastUserId": "9e52d877-4dab-4aa6-95be-c7ba5d685689" +} diff --git a/tests/data/blueprint/list-empty.json b/tests/data/blueprint/list-empty.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/data/blueprint/list-empty.json @@ -0,0 +1 @@ +{} diff --git a/tests/data/blueprint/list-valid.json b/tests/data/blueprint/list-valid.json new file mode 100644 index 0000000..284d7ec --- /dev/null +++ b/tests/data/blueprint/list-valid.json @@ -0,0 +1,16 @@ +{ + "blueprints": [ + { + "id": "60ec30c9-741f-4acb-b5f5-794934987802", + "name": "Web App Reference Architecture", + "tags": null, + "readAccess": null, + "writeAccess": null, + "createdAt": "2023-04-01T21:02:10.781Z", + "updatedAt": "2023-04-01T21:02:10.781Z", + "CreatorId": "9e52d877-4dab-4aa6-95be-c7ba5d685689", + "CurrentVersionId": "60ec30c9-741f-4acb-b5f5-794934987802", + "LastUserId": "9e52d877-4dab-4aa6-95be-c7ba5d685689" + } + ] +} diff --git a/tests/data/user/me-invalid.json b/tests/data/user/me-invalid.json new file mode 100644 index 0000000..d060020 --- /dev/null +++ b/tests/data/user/me-invalid.json @@ -0,0 +1,11 @@ +{ + "id": "b92570ba-8969-4e41-b6a3-3d672b44f9f5", + "name": "Go SDK", + "email": "hi@example.com", + "settings": { + "currency": "USD", + "firstTime": false, + }, + "createdAt": "2022-10-10T16:52:40.771Z", + "updatedAt": "2023-11-08T14:44:28.872Z", + "accessedAt": "2023-11-08T14:44:28.872Z" diff --git a/tests/data/user/me-valid.json b/tests/data/user/me-valid.json new file mode 100644 index 0000000..756f868 --- /dev/null +++ b/tests/data/user/me-valid.json @@ -0,0 +1,12 @@ +{ + "id": "b92570ba-8969-4e41-b6a3-3d672b44f9f5", + "name": "Go SDK", + "email": "hi@example.com", + "settings": { + "currency": "USD", + "firstTime": false + }, + "createdAt": "2022-10-10T16:52:40.771Z", + "updatedAt": "2023-11-08T14:44:28.872Z", + "accessedAt": "2023-11-08T14:44:28.872Z" +} diff --git a/tests/integration/aws_test.go b/tests/integration/aws_test.go new file mode 100644 index 0000000..7b21309 --- /dev/null +++ b/tests/integration/aws_test.go @@ -0,0 +1,103 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package integration_test + +import ( + "bytes" + "context" + "image/png" + "testing" + + "github.com/DataDog/cloudcraft-go" + "github.com/DataDog/cloudcraft-go/internal/xtesting" +) + +const _envAWSRoleARN string = "CLOUDCRAFT_TEST_AWS_ROLE_ARN" + +func TestAWS(t *testing.T) { + t.Parallel() + + var ( + client = xtesting.SetupLiveClient(t) + arn = xtesting.GetEnv(t, _envAWSRoleARN) + ctx = context.Background() + ) + + give := &cloudcraft.AWSAccount{ + Name: xtesting.UniqueName(t), + RoleARN: arn, + } + + account, _, err := client.AWS.Create(ctx, give) + if err != nil { + t.Fatalf("failed to create AWS account: %v", err) + } + + if account == nil { + t.Fatalf("AWS account is nil") + } + + accounts, _, err := client.AWS.List(ctx) + if err != nil { + t.Fatalf("failed to list AWS accounts: %v", err) + } + + if len(accounts) == 0 { + t.Fatalf("no AWS accounts found") + } + + var ( + accountID = account.ID + accountName = account.Name + ) + + give = &cloudcraft.AWSAccount{ + ID: accountID, + Name: accountName + _updatedSuffix, + RoleARN: arn, + } + + _, err = client.AWS.Update(ctx, give) + if err != nil { + t.Fatalf("failed to update AWS account: %v", err) + } + + snapshot, _, err := client.AWS.Snapshot(ctx, accountID, "us-east-1", "", nil) + if err != nil { + t.Fatalf("failed to snapshot AWS account: %v", err) + } + + snapshotData, err := png.DecodeConfig(bytes.NewReader(snapshot)) + if err != nil { + t.Fatalf("failed to decode PNG: %v", err) + } + + if snapshotData.Width != 1920 || snapshotData.Height != 1080 { + t.Fatalf("unexpected snapshot size: %dx%d", snapshotData.Width, snapshotData.Height) + } + + _, err = client.AWS.Delete(ctx, accountID) + if err != nil { + t.Fatalf("failed to delete AWS account: %v", err) + } + + parameters, _, err := client.AWS.IAMParameters(ctx) + if err != nil { + t.Fatalf("failed to get IAM parameters: %v", err) + } + + if parameters == nil { + t.Fatalf("IAM parameters are nil") + } + + policies, _, err := client.AWS.IAMPolicy(ctx) + if err != nil { + t.Fatalf("failed to get IAM policies: %v", err) + } + + if policies == nil { + t.Fatalf("IAM policies are nil") + } +} diff --git a/tests/integration/azure_test.go b/tests/integration/azure_test.go new file mode 100644 index 0000000..3dd620d --- /dev/null +++ b/tests/integration/azure_test.go @@ -0,0 +1,99 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package integration_test + +import ( + "bytes" + "context" + "image/png" + "testing" + + "github.com/DataDog/cloudcraft-go" + "github.com/DataDog/cloudcraft-go/internal/xtesting" +) + +const ( + _envAzureApplicationID string = "CLOUDCRAFT_TEST_AZURE_APPLICATION_ID" + _envAzureDirectoryID string = "CLOUDCRAFT_TEST_AZURE_DIRECTORY_ID" + _envAzureSubscriptionID string = "CLOUDCRAFT_TEST_AZURE_SUBSCRIPTION_ID" + _envAzureClientSecret string = "CLOUDCRAFT_TEST_AZURE_CLIENT_SECRET" +) + +func TestAzure(t *testing.T) { + t.Parallel() + + var ( + client = xtesting.SetupLiveClient(t) + ctx = context.Background() + appID = xtesting.GetEnv(t, _envAzureApplicationID) + directoryID = xtesting.GetEnv(t, _envAzureDirectoryID) + subscriptionID = xtesting.GetEnv(t, _envAzureSubscriptionID) + clientSecret = xtesting.GetEnv(t, _envAzureClientSecret) + ) + + give := &cloudcraft.AzureAccount{ + Name: xtesting.UniqueName(t), + ApplicationID: appID, + DirectoryID: directoryID, + SubscriptionID: subscriptionID, + ClientSecret: clientSecret, + } + + account, _, err := client.Azure.Create(ctx, give) + if err != nil { + t.Fatalf("failed to create Azure account: %v", err) + } + + if account == nil { + t.Fatalf("Azure account is nil") + } + + accounts, _, err := client.Azure.List(ctx) + if err != nil { + t.Fatalf("failed to list Azure accounts: %v", err) + } + + if len(accounts) == 0 { + t.Fatalf("no Azure accounts found") + } + + var ( + accountID = account.ID + accountName = account.Name + ) + + give = &cloudcraft.AzureAccount{ + ID: accountID, + Name: accountName + _updatedSuffix, + ApplicationID: appID, + DirectoryID: directoryID, + SubscriptionID: subscriptionID, + ClientSecret: clientSecret, + } + + _, err = client.Azure.Update(ctx, give) + if err != nil { + t.Fatalf("failed to update Azure account: %v", err) + } + + snapshot, _, err := client.Azure.Snapshot(ctx, accountID, "brazilsouth", "", nil) + if err != nil { + t.Fatalf("failed to snapshot Azure account: %v", err) + } + + snapshotData, err := png.DecodeConfig(bytes.NewReader(snapshot)) + if err != nil { + t.Fatalf("failed to decode PNG: %v", err) + } + + if snapshotData.Width != 1920 || snapshotData.Height != 1080 { + t.Fatalf("unexpected snapshot size: %dx%d", snapshotData.Width, snapshotData.Height) + } + + _, err = client.Azure.Delete(ctx, accountID) + if err != nil { + t.Fatalf("failed to delete Azure account: %v", err) + } +} diff --git a/tests/integration/blueprint_test.go b/tests/integration/blueprint_test.go new file mode 100644 index 0000000..d72de64 --- /dev/null +++ b/tests/integration/blueprint_test.go @@ -0,0 +1,100 @@ +package integration_test + +import ( + "bytes" + "context" + "image/png" + "testing" + + "github.com/DataDog/cloudcraft-go" + "github.com/DataDog/cloudcraft-go/internal/xtesting" +) + +func TestBlueprint(t *testing.T) { + t.Parallel() + + var ( + client = xtesting.SetupLiveClient(t) + ctx = context.Background() + ) + + give := &cloudcraft.Blueprint{ + Data: &cloudcraft.BlueprintData{ + Name: xtesting.UniqueName(t), + }, + } + + blueprint, _, err := client.Blueprint.Create(ctx, give) + if err != nil { + t.Fatalf("failed to create blueprint: %v", err) + } + + if blueprint == nil { + t.Fatalf("blueprint is nil") + } + + blueprints, _, err := client.Blueprint.List(ctx) + if err != nil { + t.Fatalf("failed to list blueprints: %v", err) + } + + if len(blueprints) == 0 { + t.Fatalf("no blueprints found") + } + + var ( + blueprintID = blueprint.ID + blueprintName = blueprint.Name + ) + + give = &cloudcraft.Blueprint{ + ID: blueprintID, + Name: blueprintName + _updatedSuffix, + Data: &cloudcraft.BlueprintData{ + Name: blueprintName + _updatedSuffix, + Nodes: []map[string]any{ + { + "id": "98172baa-a059-4b04-832d-8a7f5d14b595", + "type": "ec2", + "region": "us-east-1", + "platform": "linux", + "instanceType": "m5", + "instanceSize": "large", + }, + }, + }, + } + + _, err = client.Blueprint.Update(ctx, give, "") + if err != nil { + t.Fatalf("failed to update blueprint: %v", err) + } + + blueprint, _, err = client.Blueprint.Get(ctx, blueprintID) + if err != nil { + t.Fatalf("failed to get blueprint: %v", err) + } + + if blueprint.Name != give.Name { + t.Fatalf("blueprint name not updated") + } + + image, _, err := client.Blueprint.ExportImage(ctx, blueprintID, "png", nil) + if err != nil { + t.Fatalf("failed to export blueprint image: %v", err) + } + + imageData, err := png.DecodeConfig(bytes.NewReader(image)) + if err != nil { + t.Fatalf("failed to decode blueprint image: %v", err) + } + + if imageData.Width != 1920 || imageData.Height != 1080 { + t.Fatalf("unexpected image size: %dx%d", imageData.Width, imageData.Height) + } + + _, err = client.Blueprint.Delete(ctx, blueprintID) + if err != nil { + t.Fatalf("failed to delete blueprint: %v", err) + } +} diff --git a/tests/integration/doc.go b/tests/integration/doc.go new file mode 100644 index 0000000..5d85a74 --- /dev/null +++ b/tests/integration/doc.go @@ -0,0 +1,3 @@ +// Package integration contains live integration tests for Cloudcraft's +// developer API. +package integration diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go new file mode 100644 index 0000000..f3e88ab --- /dev/null +++ b/tests/integration/integration_test.go @@ -0,0 +1,24 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package integration_test + +import ( + "flag" + "os" + "testing" +) + +const _updatedSuffix string = " (Updated)" + +func TestMain(m *testing.M) { + // Call flag.Parse explicitly to prevent testing.Short() from panicking. + flag.Parse() + + if testing.Short() { + os.Exit(0) + } + + os.Exit(m.Run()) +} diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go new file mode 100644 index 0000000..f762457 --- /dev/null +++ b/tests/integration/user_test.go @@ -0,0 +1,30 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package integration_test + +import ( + "context" + "testing" + + "github.com/DataDog/cloudcraft-go/internal/xtesting" +) + +func TestUser(t *testing.T) { + t.Parallel() + + var ( + client = xtesting.SetupLiveClient(t) + ctx = context.Background() + ) + + user, _, err := client.User.Me(ctx) + if err != nil { + t.Fatalf("failed to get user: %v", err) + } + + if user == nil { + t.Fatalf("user is nil") + } +} diff --git a/user.go b/user.go new file mode 100644 index 0000000..94a3a9b --- /dev/null +++ b/user.go @@ -0,0 +1,71 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// userPath is the path to the user endpoint of the Cloudcraft API. +const userPath string = "user" + +// UserService handles communication with the "/user" endpoint of Cloudcraft's +// developer API. +type UserService service + +// User represents a Cloudcraft user. +type User struct { + AccessedAt time.Time `json:"accessedAt,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` + Settings map[string]any `json:"settings,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` +} + +// Me returns the user profile. +// +// [API reference]. +// +// [API reference]: https://developers.cloudcraft.co/#a1ac9d21-3d47-4338-b171-8419872f818a +func (s *UserService) Me(ctx context.Context) (*User, *Response, error) { + if ctx == nil { + return nil, nil, ErrNilContext + } + + var ( + baseURL = s.client.cfg.endpoint.String() + endpoint strings.Builder + ) + + endpoint.Grow(len(baseURL) + len(userPath) + 3) + + endpoint.WriteString(baseURL) + endpoint.WriteString(userPath) + endpoint.WriteString("/me") + + req, err := s.client.request(ctx, http.MethodGet, endpoint.String(), http.NoBody) + if err != nil { + return nil, nil, err + } + + ret, err := s.client.do(ctx, req) + if err != nil { + return nil, nil, err + } + + var user *User + if err := json.Unmarshal(ret.Body, &user); err != nil { + return nil, nil, fmt.Errorf("%w", err) + } + + return user, ret, nil +} diff --git a/user_test.go b/user_test.go new file mode 100644 index 0000000..15a60b0 --- /dev/null +++ b/user_test.go @@ -0,0 +1,115 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache-2.0 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2023-Present Datadog, Inc. + +package cloudcraft_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "reflect" + "testing" + + "github.com/DataDog/cloudcraft-go" + "github.com/DataDog/cloudcraft-go/internal/xtesting" +) + +const _testUserDataPath string = "tests/data/user" + +func TestUserService_Me(t *testing.T) { + t.Parallel() + + var ( + validTestData = xtesting.ReadFile(t, filepath.Join(_testUserDataPath, "me-valid.json")) + invalidTestData = xtesting.ReadFile(t, filepath.Join(_testUserDataPath, "me-invalid.json")) + ctx = context.Background() + ) + + tests := []struct { + name string + handler http.HandlerFunc + context context.Context + want *cloudcraft.User + wantErr bool + }{ + { + name: "Valid user data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(validTestData) + }, + context: ctx, + want: &cloudcraft.User{ + ID: "b92570ba-8969-4e41-b6a3-3d672b44f9f5", + Name: "Go SDK", + Email: "hi@example.com", + Settings: map[string]any{ + "currency": "USD", + "firstTime": false, + }, + CreatedAt: xtesting.ParseTime(t, "2022-10-10T16:52:40.771Z"), + UpdatedAt: xtesting.ParseTime(t, "2023-11-08T14:44:28.872Z"), + AccessedAt: xtesting.ParseTime(t, "2023-11-08T14:44:28.872Z"), + }, + wantErr: false, + }, + { + name: "Invalid user data", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + + w.Write(invalidTestData) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "API error response", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + context: ctx, + want: nil, + wantErr: true, + }, + { + name: "Nil context", + handler: func(w http.ResponseWriter, r *http.Request) {}, + context: nil, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(tt.handler) + defer server.Close() + + endpoint, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + client := xtesting.SetupMockClient(t, endpoint) + + got, _, err := client.User.Me(tt.context) + if (err != nil) != tt.wantErr { + t.Fatalf("UserService.Me() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Fatalf("UserService.Me() = %v, want %v", got, tt.want) + } + }) + } +}