From fdb0a9e048a1cd19526876d892c63f20b04a9837 Mon Sep 17 00:00:00 2001 From: Mo Omer Date: Fri, 3 May 2024 12:04:42 -0500 Subject: [PATCH] Cluster Client; switch to a different router to allow tests to keep encapsulation and share a singular multiplexer/router --- bonsai/client.go | 2 + bonsai/client_test.go | 5 +- bonsai/cluster.go | 461 ++++++++++++++++++++++++++++++++++++ bonsai/cluster_impl_test.go | 59 +++++ bonsai/cluster_test.go | 454 +++++++++++++++++++++++++++++++++++ bonsai/plan.go | 32 ++- bonsai/release.go | 9 +- bonsai/space.go | 9 +- go.mod | 2 + go.sum | 6 + 10 files changed, 1022 insertions(+), 17 deletions(-) create mode 100644 bonsai/cluster.go create mode 100644 bonsai/cluster_impl_test.go create mode 100644 bonsai/cluster_test.go diff --git a/bonsai/client.go b/bonsai/client.go index 69c7d5f..af56a33 100644 --- a/bonsai/client.go +++ b/bonsai/client.go @@ -325,6 +325,7 @@ type Client struct { Space SpaceClient Plan PlanClient Release ReleaseClient + Cluster ClusterClient } func NewClient(options ...ClientOption) *Client { @@ -345,6 +346,7 @@ func NewClient(options ...ClientOption) *Client { client.Space = SpaceClient{client} client.Plan = PlanClient{client} client.Release = ReleaseClient{client} + client.Cluster = ClusterClient{client} return client } diff --git a/bonsai/client_test.go b/bonsai/client_test.go index c8ed385..8b7260e 100644 --- a/bonsai/client_test.go +++ b/bonsai/client_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + "github.com/go-chi/chi/v5" "github.com/omc/bonsai-api-go/v1/bonsai" ) @@ -34,7 +35,7 @@ type ClientTestSuite struct { suite.Suite // serveMux is the request multiplexer used for tests - serveMux *http.ServeMux + serveMux *chi.Mux // server is the testing server on some local port server *httptest.Server // client allows each test to have a reachable *bonsai.Client for testing @@ -43,7 +44,7 @@ type ClientTestSuite struct { func (s *ClientTestSuite) SetupSuite() { // Configure http client and other miscellany - s.serveMux = http.NewServeMux() + s.serveMux = chi.NewRouter() s.server = httptest.NewServer(s.serveMux) token, err := bonsai.NewToken("TestToken") if err != nil { diff --git a/bonsai/cluster.go b/bonsai/cluster.go new file mode 100644 index 0000000..2dc4e1e --- /dev/null +++ b/bonsai/cluster.go @@ -0,0 +1,461 @@ +package bonsai + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "reflect" + + "github.com/google/go-querystring/query" +) + +const ( + ClusterAPIBasePath = "/clusters" +) + +// ClusterStats holds *some* statistics about the cluster. +// +// This attribute should not be used for real-time monitoring! +// Stats are updated every 10-15 minutes. To monitor real-time metrics, monitor +// your cluster directly, via the Index Stats API. +type ClusterStats struct { + // Number of documents in the index. + Docs int64 `json:"docs,omitempty"` + // Number of shards the cluster is using. + ShardsUsed int64 `json:"shards_used,omitempty"` + // Number of bytes the cluster is using on-disk. + DataBytesUsed int64 `json:"data_bytes_used,omitempty"` +} + +// ClusterAccess holds information about connecting to the cluster. +type ClusterAccess struct { + // Host name of the cluster + Host string `json:"host"` + // HTTP Port the cluster is running on. + Port int `json:"port"` + // HTTP Scheme needed to access the cluster. Default: "https". + Scheme string `json:"scheme"` + + // User holds the username to access the cluster with. + // Only shown once, during cluster creation. + Username string `json:"user,omitempty"` + // Pass holds the password to access the cluster with. + // Only shown once, during cluster creation. + Password string `json:"pass,omitempty"` + // URL is the Cluster endpoint for access. + // Only shown once, during cluster creation. + URL string `json:"url,omitempty"` +} + +// ClusterState represents the current state of the cluster, indicating what +// the cluster is doing at any given moment. +type ClusterState string + +const ( + ClusterStateDeprovisioned ClusterState = "DEPROVISIONED" + ClusterStateDeprovisioning ClusterState = "DEPROVISIONING" + ClusterStateDisabled ClusterState = "DISABLED" + ClusterStateMaintenance ClusterState = "MAINTENANCE" + ClusterStateProvisioned ClusterState = "PROVISIONED" + ClusterStateProvisioning ClusterState = "PROVISIONING" + ClusterStateReadOnly ClusterState = "READONLY" + ClusterStateUpdatingPlan ClusterState = "UPDATING PLAN" +) + +// Cluster represents a subscription cluster. +type Cluster struct { + // Slug represents a unique, machine-readable name for the cluster. + // A cluster slug is based its name at creation, to which a random integer + // is concatenated. + Slug string `json:"slug"` + // Name is the human-readable name of the cluster. + Name string `json:"name"` + // URI is a machine-readable name for the cluster. + URI string `json:"uri"` + + // Plan holds some information about the cluster's current subscription plan. + Plan Plan `json:"plan"` + // Release holds some information about the cluster's current release. + Release Release `json:"release"` + + // Space holds some information about where the cluster is running. + Space Space `json:"space"` + + // Stats holds a collection of statistics about the cluster. + Stats ClusterStats `json:"stats"` + + // ClusterAccess holds information about connecting to the cluster. + Access ClusterAccess `json:"access"` + + // State represents the current state of the cluster. This indicates what + // the cluster is doing at any given moment. + State ClusterState `json:"state"` +} + +// ClustersResultList is a wrapper around a slice of +// Clusters for json unmarshaling. +type ClustersResultList struct { + Clusters []Cluster `json:"clusters"` +} + +// ClustersResultCreate is the result response for Create (POST) requests to the +// clusters endpoint. +type ClustersResultCreate struct { + // Message contains details about the cluster creation request. + Message string `json:"message"` + // Monitor holds a URI to the Cluster overview page. + Monitor string `json:"monitor"` + Access ClusterAccess `json:"access"` +} + +// ClustersResultUpdate is the result response for Update (PUT) requests to the +// clusters endpoint. +type ClustersResultUpdate struct { + // Message contains details about the cluster update request. + Message string `json:"message"` + // Monitor holds a URI to the Cluster overview page. + Monitor string `json:"monitor"` +} + +// ClustersResultDestroy is the result response for Destroy (DELETE) requests to the +// clusters endpoint. +type ClustersResultDestroy struct { + // Message contains details about the cluster destroy request. + Message string `json:"message"` + // Monitor holds a URI to the Cluster overview page. + Monitor string `json:"monitor"` +} + +// ClusterClient is a client for the Clusters API. +type ClusterClient struct { + *Client +} + +type ClusterAllOpts struct { + // Optional. A query string for filtering matching clusters. + // This currently works on name. + Query string `url:"q,omitempty"` + // Optional. A string which will constrain results to parent or child + // cluster. Valid values are: "parent", "child" + Tenancy string `url:"tenancy,omitempty"` + // Optional. A string representing the account, region, space, or cluster + // path where the cluster is located. You can get a list of available spaces + // with the [bonsai.SpaceClient] API. Space path prefixes work here, so you + // can find all clusters in a given region for a given cloud. + Location string `url:"location,omitempty"` +} + +type ClusterCreateOpts struct { + // Required. A String representing the name for the new cluster. + Name string `json:"name"` + // The slug of the Plan that the new cluster will be configured for. + // Use the [PlanClient.All] method to view a list of all Plans available. + Plan string `json:"plan,omitempty"` + // The slug of the Space where the new cluster should be deployed to. + // Use the [SpaceClient.All] method to view a list of all Spaces. + Space string `json:"space,omitempty"` + // The Search Service Release that the new cluster will use. + // Use the [ReleaseClient.All] method to view a list of all Spaces. + Release string `json:"release,omitempty"` +} + +func (o ClusterCreateOpts) Valid() error { + if o.Name == "" { + return errors.New("name can't be empty") + } + return nil +} + +type ClusterUpdateOpts struct { + // Required. A String representing the name for the new cluster. + Name string `json:"name"` + // The slug of the Plan that the new cluster will be configured for. + // Use the [PlanClient.All] method to view a list of all Plans available. + Plan string `json:"plan,omitempty"` +} + +func (o ClusterUpdateOpts) Valid() error { + if o.Name == "" { + return errors.New("name can't be empty") + } + return nil +} + +type clusterListOpts struct { + listOpts + ClusterAllOpts +} + +func (o clusterListOpts) values() (url.Values, error) { + queryValues := o.listOpts.values() + + clusterValues, err := query.Values(o.ClusterAllOpts) + if err != nil { + return nil, fmt.Errorf("error parsing cluster list options: %w", err) + } + + for k, v := range clusterValues { + queryValues[k] = v + } + + return queryValues, nil +} + +// list returns a list of Clusters for the page specified, +// by performing a GET request against [spaceAPIBasePath]. +// +// Note: Pagination is not currently supported. +func (c *ClusterClient) list(ctx context.Context, opt clusterListOpts) ( + []Cluster, + *Response, + error, +) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + ) + // Let's make some initial capacity to reduce allocations + results := ClustersResultList{ + Clusters: make([]Cluster, 0, defaultResponseCapacity), + } + + reqURL, err = url.Parse(ClusterAPIBasePath) + if err != nil { + return results.Clusters, nil, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ClusterAPIBasePath, err) + } + + // Conditionally set options if we received any + if !reflect.ValueOf(opt).IsZero() { + var optVals url.Values + + optVals, err = opt.values() + if err != nil { + return results.Clusters, nil, fmt.Errorf("failed to get values from opt (%+v): %w", opt, err) + } + + reqURL.RawQuery = optVals.Encode() + } + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return results.Clusters, nil, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return results.Clusters, resp, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &results); err != nil { + return results.Clusters, resp, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return results.Clusters, resp, nil +} + +// All lists all Clusters from the Clusters API. +func (c *ClusterClient) All(ctx context.Context) ([]Cluster, error) { + var ( + err error + resp *Response + ) + + allResults := make([]Cluster, 0, defaultListResultSize) + // No pagination support as yet, but support it for future use + + err = c.all(ctx, newEmptyListOpts(), func(opt listOpts) (*Response, error) { + var listResults []Cluster + + listResults, resp, err = c.list(ctx, clusterListOpts{listOpts: opt}) + if err != nil { + return resp, fmt.Errorf("client.list failed: %w", err) + } + + allResults = append(allResults, listResults...) + if len(allResults) >= resp.PageSize { + resp.MarkPaginationComplete() + } + return resp, err + }) + + if err != nil { + return allResults, fmt.Errorf("client.all failed: %w", err) + } + + return allResults, err +} + +// GetBySlug gets a Cluster from the Clusters API by its slug. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *ClusterClient) GetBySlug(ctx context.Context, slug string) (Cluster, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + result Cluster + ) + + reqURL, err = url.Parse(ClusterAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ClusterAPIBasePath, err) + } + + reqURL.Path = path.Join(reqURL.Path, slug) + + req, err = c.NewRequest(ctx, "GET", reqURL.String(), nil) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil +} + +// Create requests a new Cluster to be created. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *ClusterClient) Create(ctx context.Context, opt ClusterCreateOpts) ( + ClustersResultCreate, + error, +) { + var ( + req *http.Request + reqURL *url.URL + reqBody []byte + resp *Response + err error + ) + // Let's make some initial capacity to reduce allocations + result := ClustersResultCreate{} + + reqURL, err = url.Parse(ClusterAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ClusterAPIBasePath, err) + } + + if err = opt.Valid(); err != nil { + return result, fmt.Errorf("invalid create options (%v): %w", opt, err) + } + + reqBody, err = json.Marshal(opt) + if err != nil { + return result, fmt.Errorf("failed to marshal options (%v): %w", opt, err) + } + + req, err = c.NewRequest(ctx, "POST", reqURL.String(), bytes.NewReader(reqBody)) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil +} + +// Update requests a new Cluster be updated. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *ClusterClient) Update(ctx context.Context, opt ClusterUpdateOpts) ( + ClustersResultUpdate, + error, +) { + var ( + req *http.Request + reqURL *url.URL + reqBody []byte + resp *Response + err error + ) + // Let's make some initial capacity to reduce allocations + result := ClustersResultUpdate{} + + reqURL, err = url.Parse(ClusterAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ClusterAPIBasePath, err) + } + + if err = opt.Valid(); err != nil { + return result, fmt.Errorf("invalid create options (%v): %w", opt, err) + } + + reqBody, err = json.Marshal(opt) + if err != nil { + return result, fmt.Errorf("failed to marshal options (%v): %w", opt, err) + } + + req, err = c.NewRequest(ctx, "PUT", reqURL.String(), bytes.NewReader(reqBody)) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil +} + +// Destroy triggers the deprovisioning of the cluster associated with the slug. +// +//nolint:dupl // Allow duplicated code blocks in code paths that may change +func (c *ClusterClient) Destroy(ctx context.Context, slug string) (ClustersResultDestroy, error) { + var ( + req *http.Request + reqURL *url.URL + resp *Response + err error + result ClustersResultDestroy + ) + + reqURL, err = url.Parse(ClusterAPIBasePath) + if err != nil { + return result, fmt.Errorf("cannot parse relative url from basepath (%s): %w", ClusterAPIBasePath, err) + } + + reqURL.Path = path.Join(reqURL.Path, slug) + + req, err = c.NewRequest(ctx, "DELETE", reqURL.String(), nil) + if err != nil { + return result, fmt.Errorf("creating new http request for URL (%s): %w", reqURL.String(), err) + } + + resp, err = c.Do(ctx, req) + if err != nil { + return result, fmt.Errorf("client.do failed: %w", err) + } + + if err = json.Unmarshal(resp.BodyBuf.Bytes(), &result); err != nil { + return result, fmt.Errorf("json.Unmarshal failed: %w", err) + } + + return result, nil +} diff --git a/bonsai/cluster_impl_test.go b/bonsai/cluster_impl_test.go new file mode 100644 index 0000000..96e10c0 --- /dev/null +++ b/bonsai/cluster_impl_test.go @@ -0,0 +1,59 @@ +package bonsai + +import "net/url" + +func (s *ClientImplTestSuite) TestClusterListOptsValues() { + testCases := []struct { + name string + received clusterListOpts + expect url.Values + }{ + { + name: "with populated values", + received: clusterListOpts{ + listOpts: listOpts{ + Page: 3, + Size: 100, + }, + ClusterAllOpts: ClusterAllOpts{ + Query: "a query string", + Tenancy: "parent", + Location: "omc/bonsai/us-east-1/common", + }, + }, + expect: url.Values{ + "page": []string{"3"}, + "size": []string{"100"}, + "q": []string{"a query string"}, + "tenancy": []string{"parent"}, + "location": []string{"omc/bonsai/us-east-1/common"}, + }, + }, + { + name: "with pagination, but empty ClusterAllOpts values", + received: clusterListOpts{ + listOpts: listOpts{ + Page: 3, + Size: 100, + }, + }, + expect: url.Values{ + "page": []string{"3"}, + "size": []string{"100"}, + }, + }, + { + name: "with empty values", + received: clusterListOpts{}, + expect: url.Values{}, + }, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + receivedVal, err := tc.received.values() + s.NoError(err, "received values values()") + s.Equal(receivedVal, tc.expect) + }) + } +} diff --git a/bonsai/cluster_test.go b/bonsai/cluster_test.go new file mode 100644 index 0000000..07f2202 --- /dev/null +++ b/bonsai/cluster_test.go @@ -0,0 +1,454 @@ +package bonsai_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/omc/bonsai-api-go/v1/bonsai" +) + +func (s *ClientTestSuite) TestClusterClient_All() { + s.serveMux.Get(bonsai.ClusterAPIBasePath, func(w http.ResponseWriter, _ *http.Request) { + respStr := ` + { + "pagination": { + "page_number": 1, + "page_size": 10, + "total_records": 3 + }, + "clusters": [ + { + "slug": "first-testing-cluste-1234567890", + "name": "first_testing_cluster", + "uri": "https://api.bonsai.io/clusters/first-testing-cluste-1234567890", + "plan": { + "slug": "sandbox-aws-us-east-1", + "uri": "https://api.bonsai.io/plans/sandbox-aws-us-east-1" + }, + "release": { + "version": "7.2.0", + "slug": "elasticsearch-7.2.0", + "package_name": "7.2.0", + "service_type": "elasticsearch", + "uri": "https://api.bonsai.io/releases/elasticsearch-7.2.0" + }, + "space": { + "path": "omc/bonsai/us-east-1/common", + "region": "aws-us-east-1", + "uri": "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common" + }, + "stats": { + "docs": 0, + "shards_used": 0, + "data_bytes_used": 0 + }, + "access": { + "host": "first-testing-cluste-1234567890.us-east-1.bonsaisearch.net", + "port": 443, + "scheme": "https" + }, + "state": "PROVISIONED" + }, + { + "slug": "second-testing-clust-1234567890", + "name": "second_testing_cluster", + "uri": "https://api.bonsai.io/clusters/second-testing-clust-1234567890", + "plan": { + "slug": "sandbox-aws-us-east-1", + "uri": "https://api.bonsai.io/plans/sandbox-aws-us-east-1" + }, + "release": { + "version": "7.2.0", + "slug": "elasticsearch-7.2.0", + "package_name": "7.2.0", + "service_type": "elasticsearch", + "uri": "https://api.bonsai.io/releases/elasticsearch-7.2.0" + }, + "space": { + "path": "omc/bonsai/us-east-1/common", + "region": "aws-us-east-1", + "uri": "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common" + }, + "stats": { + "docs": 0, + "shards_used": 0, + "data_bytes_used": 0 + }, + "access": { + "host": "second-testing-clust-1234567890.us-east-1.bonsaisearch.net", + "port": 443, + "scheme": "https" + }, + "state": "PROVISIONED" + }, + { + "slug": "third-testing-clust-1234567890", + "name": "third_testing_cluster", + "uri": "https://api.bonsai.io/clusters/third-testing-clust-1234567890", + "plan": { + "slug": "sandbox-aws-us-east-1", + "uri": "https://api.bonsai.io/plans/sandbox-aws-us-east-1" + }, + "release": { + "version": "7.2.0", + "slug": "elasticsearch-7.2.0", + "package_name": "7.2.0", + "service_type": "elasticsearch", + "uri": "https://api.bonsai.io/releases/elasticsearch-7.2.0" + }, + "space": { + "path": "omc/bonsai/us-east-1/common", + "region": "aws-us-east-1", + "uri": "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common" + }, + "stats": { + "docs": 1500000, + "shards_used": 14, + "data_bytes_used": 93180912390 + }, + "access": { + "host": "third-testing-clust-1234567890.us-east-1.bonsaisearch.net", + "port": 443, + "scheme": "https" + }, + "state": "PROVISIONED" + } + ] + } + ` + + resp := &bonsai.ClustersResultList{Clusters: make([]bonsai.Cluster, 0, 2)} + err := json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshal json into bonsai.ClustersResultList") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encode bonsai.ClustersResultList into json") + }) + + expect := []bonsai.Cluster{ + { + Slug: "first-testing-cluste-1234567890", + Name: "first_testing_cluster", + URI: "https://api.bonsai.io/clusters/first-testing-cluste-1234567890", + Plan: bonsai.Plan{ + Slug: "sandbox-aws-us-east-1", + AvailableReleases: []bonsai.Release{}, + AvailableSpaces: []bonsai.Space{}, + URI: "https://api.bonsai.io/plans/sandbox-aws-us-east-1", + }, + Release: bonsai.Release{ + Version: "7.2.0", + Slug: "elasticsearch-7.2.0", + PackageName: "7.2.0", + ServiceType: "elasticsearch", + URI: "https://api.bonsai.io/releases/elasticsearch-7.2.0", + }, + Space: bonsai.Space{ + Path: "omc/bonsai/us-east-1/common", + Region: "aws-us-east-1", + URI: "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common", + }, + Stats: bonsai.ClusterStats{ + Docs: 0, + ShardsUsed: 0, + DataBytesUsed: 0, + }, + Access: bonsai.ClusterAccess{ + Host: "first-testing-cluste-1234567890.us-east-1.bonsaisearch.net", + Port: 443, + Scheme: "https", + }, + State: bonsai.ClusterStateProvisioned, + }, + { + Slug: "second-testing-clust-1234567890", + Name: "second_testing_cluster", + URI: "https://api.bonsai.io/clusters/second-testing-clust-1234567890", + Plan: bonsai.Plan{ + Slug: "sandbox-aws-us-east-1", + AvailableReleases: []bonsai.Release{}, + AvailableSpaces: []bonsai.Space{}, + URI: "https://api.bonsai.io/plans/sandbox-aws-us-east-1", + }, + Release: bonsai.Release{ + Version: "7.2.0", + Slug: "elasticsearch-7.2.0", + PackageName: "7.2.0", + ServiceType: "elasticsearch", + URI: "https://api.bonsai.io/releases/elasticsearch-7.2.0", + }, + Space: bonsai.Space{ + Path: "omc/bonsai/us-east-1/common", + Region: "aws-us-east-1", + URI: "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common", + }, + Stats: bonsai.ClusterStats{ + Docs: 0, + ShardsUsed: 0, + DataBytesUsed: 0, + }, + Access: bonsai.ClusterAccess{ + Host: "second-testing-clust-1234567890.us-east-1.bonsaisearch.net", + Port: 443, + Scheme: "https", + }, + State: bonsai.ClusterStateProvisioned, + }, + { + Slug: "third-testing-clust-1234567890", + Name: "third_testing_cluster", + URI: "https://api.bonsai.io/clusters/third-testing-clust-1234567890", + Plan: bonsai.Plan{ + Slug: "sandbox-aws-us-east-1", + AvailableReleases: []bonsai.Release{}, + AvailableSpaces: []bonsai.Space{}, + URI: "https://api.bonsai.io/plans/sandbox-aws-us-east-1", + }, + Release: bonsai.Release{ + Version: "7.2.0", + Slug: "elasticsearch-7.2.0", + PackageName: "7.2.0", + ServiceType: "elasticsearch", + URI: "https://api.bonsai.io/releases/elasticsearch-7.2.0", + }, + Space: bonsai.Space{ + Path: "omc/bonsai/us-east-1/common", + Region: "aws-us-east-1", + URI: "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common", + }, + Stats: bonsai.ClusterStats{ + Docs: 1500000, + ShardsUsed: 14, + DataBytesUsed: 93180912390, + }, + Access: bonsai.ClusterAccess{ + Host: "third-testing-clust-1234567890.us-east-1.bonsaisearch.net", + Port: 443, + Scheme: "https", + }, + State: bonsai.ClusterStateProvisioned, + }, + } + clusters, err := s.client.Cluster.All(context.Background()) + s.NoError(err, "successfully get all clusters") + s.Len(clusters, 3) + + s.ElementsMatch(expect, clusters, "elements in expect match elements in received clusters") + + // Comparisons on the individual struct level are much easier to debug + for i, cluster := range clusters { + s.Run(fmt.Sprintf("Cluster #%d", i), func() { + s.Equal(expect[i], cluster) + }) + } +} + +func (s *ClientTestSuite) TestClusterClient_GetBySlug() { + const targetClusterSlug = "second-testing-clust-1234567890" + + urlPath, err := url.JoinPath(bonsai.ClusterAPIBasePath, targetClusterSlug) + s.NoError(err, "successfully resolved path") + + s.serveMux.Get(urlPath, func(w http.ResponseWriter, _ *http.Request) { + respStr := fmt.Sprintf(` + { + "slug": "%s", + "name": "second_testing_cluster", + "uri": "https://api.bonsai.io/clusters/second-testing-clust-1234567890", + "plan": { + "slug": "sandbox-aws-us-east-1", + "uri": "https://api.bonsai.io/plans/sandbox-aws-us-east-1" + }, + "release": { + "version": "7.2.0", + "slug": "elasticsearch-7.2.0", + "package_name": "7.2.0", + "service_type": "elasticsearch", + "uri": "https://api.bonsai.io/releases/elasticsearch-7.2.0" + }, + "space": { + "path": "omc/bonsai/us-east-1/common", + "region": "aws-us-east-1", + "uri": "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common" + }, + "stats": { + "docs": 0, + "shards_used": 0, + "data_bytes_used": 0 + }, + "access": { + "host": "second-testing-clust-1234567890.us-east-1.bonsaisearch.net", + "port": 443, + "scheme": "https" + }, + "state": "PROVISIONED" + } + `, targetClusterSlug) + + resp := &bonsai.Cluster{} + err = json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshals json into bonsai.Space") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encodes bonsai.Space into json on the writer") + }) + + expect := bonsai.Cluster{ + Slug: "second-testing-clust-1234567890", + Name: "second_testing_cluster", + URI: "https://api.bonsai.io/clusters/second-testing-clust-1234567890", + Plan: bonsai.Plan{ + Slug: "sandbox-aws-us-east-1", + AvailableReleases: []bonsai.Release{}, + AvailableSpaces: []bonsai.Space{}, + URI: "https://api.bonsai.io/plans/sandbox-aws-us-east-1", + }, + Release: bonsai.Release{ + Version: "7.2.0", + Slug: "elasticsearch-7.2.0", + PackageName: "7.2.0", + ServiceType: "elasticsearch", + URI: "https://api.bonsai.io/releases/elasticsearch-7.2.0", + }, + Space: bonsai.Space{ + Path: "omc/bonsai/us-east-1/common", + Region: "aws-us-east-1", + URI: "https://api.bonsai.io/spaces/omc/bonsai/us-east-1/common", + }, + Stats: bonsai.ClusterStats{ + Docs: 0, + ShardsUsed: 0, + DataBytesUsed: 0, + }, + Access: bonsai.ClusterAccess{ + Host: "second-testing-clust-1234567890.us-east-1.bonsaisearch.net", + Port: 443, + Scheme: "https", + }, + State: bonsai.ClusterStateProvisioned, + } + + resultResp, err := s.client.Cluster.GetBySlug(context.Background(), targetClusterSlug) + s.NoError(err, "successfully get cluster by path") + + s.Equal(expect, resultResp, "elements in expect match elements in received cluster response") +} + +func (s *ClientTestSuite) TestClusterClient_Create() { + s.serveMux.Post(bonsai.ClusterAPIBasePath, func(w http.ResponseWriter, _ *http.Request) { + respStr := ` + { + "message": "Your cluster is being provisioned.", + "monitor": "https://api.bonsai.io/clusters/test-5-x-3968320296", + "access": { + "user": "utji08pwu6", + "pass": "18v1fbey2y", + "host": "test-5-x-3968320296", + "port": 443, + "scheme": "https", + "url": "https://utji08pwu6:18v1fbey2y@test-5-x-3968320296.us-east-1.bonsaisearch.net:443" + }, + "status": 202 + } + ` + + resp := &bonsai.ClustersResultCreate{} + err := json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshals json into bonsai.ClustersResultCreate") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encodes bonsai.ClustersResultCreate into json on the writer") + }) + + expect := bonsai.ClustersResultCreate{ + Message: "Your cluster is being provisioned.", + Monitor: "https://api.bonsai.io/clusters/test-5-x-3968320296", + Access: bonsai.ClusterAccess{ + Host: "test-5-x-3968320296", + Port: 443, + Scheme: "https", + Username: "utji08pwu6", + Password: "18v1fbey2y", + URL: "https://utji08pwu6:18v1fbey2y@test-5-x-3968320296.us-east-1.bonsaisearch.net:443", + }, + } + + resultResp, err := s.client.Cluster.Create(context.Background(), bonsai.ClusterCreateOpts{ + Name: "test-5-x-3968320296", + Plan: "sandbox-aws-us-east-1", + Space: "omc/bonsai/us-east-1/common", + Release: "elasticsearch-7.2.0", + }) + s.NoError(err, "successfully execute create cluster request") + + s.Equal(expect, resultResp, "elements in expect match elements in received cluster create response") +} + +func (s *ClientTestSuite) TestClusterClient_Update() { + s.serveMux.Put(bonsai.ClusterAPIBasePath, func(w http.ResponseWriter, _ *http.Request) { + respStr := ` + { + "message": "Your cluster is being updated.", + "monitor": "https://api.bonsai.io/clusters/test-5-x-3968320296", + "status": 202 + } + ` + + resp := &bonsai.ClustersResultUpdate{} + err := json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshals json into bonsai.ClustersResultUpdate") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encodes bonsai.ClustersResultUpdate into json on the writer") + }) + + expect := bonsai.ClustersResultUpdate{ + Message: "Your cluster is being updated.", + Monitor: "https://api.bonsai.io/clusters/test-5-x-3968320296", + } + + resultResp, err := s.client.Cluster.Update(context.Background(), bonsai.ClusterUpdateOpts{ + Name: "test-5-x-3968320296", + Plan: "sandbox-aws-us-east-2", + }) + s.NoError(err, "successfully execute create cluster request") + + s.Equal(expect, resultResp, "items in expect match items in received cluster update response") +} + +func (s *ClientTestSuite) TestClusterClient_Delete() { + const targetClusterSlug = "second-testing-clust-1234567890" + + reqPath, err := url.JoinPath(bonsai.ClusterAPIBasePath, targetClusterSlug) + s.NoError(err, "successfully resolved path") + + s.serveMux.Delete(reqPath, func(w http.ResponseWriter, _ *http.Request) { + respStr := fmt.Sprintf(` + { + "message": "Your cluster is being deprovisioned.", + "monitor": "%s", + "status": 202 + } + `, targetClusterSlug) + + resp := &bonsai.ClustersResultDestroy{} + err = json.Unmarshal([]byte(respStr), resp) + s.NoError(err, "unmarshals json into bonsai.ClustersResultDestroy") + + err = json.NewEncoder(w).Encode(resp) + s.NoError(err, "encodes bonsai.ClustersResultDestroy into json on the writer") + }) + + expect := bonsai.ClustersResultDestroy{ + Message: "Your cluster is being deprovisioned.", + Monitor: targetClusterSlug, + } + + resultResp, err := s.client.Cluster.Destroy(context.Background(), targetClusterSlug) + s.NoError(err, "successfully execute create cluster request") + + s.Equal(expect, resultResp, "items in expect match items in received cluster update response") +} diff --git a/bonsai/plan.go b/bonsai/plan.go index a1e08b3..2f2cba0 100644 --- a/bonsai/plan.go +++ b/bonsai/plan.go @@ -19,28 +19,34 @@ const ( // // It differs from Plan namely in that the AvailableReleases returned is // a list of string, not Release. +// +// Indeed, it exists to resolve differences between index list response and +// other response structures. type planAllResponse struct { // Represents a machine-readable name for the plan. - Slug string `json:"slug"` + Slug string `json:"slug,omitempty"` // Represents the human-readable name of the plan. - Name string `json:"name"` + Name string `json:"name,omitempty"` // Represents the plan price in cents. - PriceInCents int64 `json:"price_in_cents"` + PriceInCents int64 `json:"price_in_cents,omitempty"` // Represents the plan billing interval in months. - BillingIntervalInMonths int `json:"billing_interval_in_months"` + BillingIntervalInMonths int `json:"billing_interval_in_months,omitempty"` // Indicates whether the plan is single-tenant or not. A value of false // indicates the Cluster will share hardware with other Clusters. Single // tenant environments can be reached via the public Internet. - SingleTenant bool `json:"single_tenant"` + SingleTenant bool `json:"single_tenant,omitempty"` // Indicates whether the plan is on a publicly addressable network. // Private plans provide environments that cannot be reached by the public // Internet. A VPC connection will be needed to communicate with a private // cluster. - PrivateNetwork bool `json:"private_network"` + PrivateNetwork bool `json:"private_network,omitempty"` // A collection of search release slugs available for the plan. Additional // information about a release can be retrieved from the Releases API. AvailableReleases []string `json:"available_releases"` AvailableSpaces []string `json:"available_spaces"` + + // A URI to retrieve more information about this Plan. + URI string `json:"uri,omitempty"` } type planAllResponseList struct { @@ -68,6 +74,7 @@ func (c *planAllResponseConverter) Convert(source planAllResponse) Plan { for i, space := range source.AvailableSpaces { plan.AvailableSpaces[i] = Space{Path: space} } + plan.URI = source.URI return plan } @@ -90,24 +97,27 @@ type Plan struct { // Represents a machine-readable name for the plan. Slug string `json:"slug"` // Represents the human-readable name of the plan. - Name string `json:"name"` + Name string `json:"name,omitempty"` // Represents the plan price in cents. - PriceInCents int64 `json:"price_in_cents"` + PriceInCents int64 `json:"price_in_cents,omitempty"` // Represents the plan billing interval in months. - BillingIntervalInMonths int `json:"billing_interval_months"` + BillingIntervalInMonths int `json:"billing_interval_months,omitempty"` // Indicates whether the plan is single-tenant or not. A value of false // indicates the Cluster will share hardware with other Clusters. Single // tenant environments can be reached via the public Internet. - SingleTenant bool `json:"single_tenant"` + SingleTenant bool `json:"single_tenant,omitempty"` // Indicates whether the plan is on a publicly addressable network. // Private plans provide environments that cannot be reached by the public // Internet. A VPC connection will be needed to communicate with a private // cluster. - PrivateNetwork bool `json:"private_network"` + PrivateNetwork bool `json:"private_network,omitempty"` // A collection of search release slugs available for the plan. Additional // information about a release can be retrieved from the Releases API. AvailableReleases []Release `json:"available_releases"` AvailableSpaces []Space `json:"available_spaces"` + + // A URI to retrieve more information about this Plan. + URI string `json:"uri,omitempty"` } func (p *Plan) UnmarshalJSON(data []byte) error { diff --git a/bonsai/release.go b/bonsai/release.go index 89d296a..7089087 100644 --- a/bonsai/release.go +++ b/bonsai/release.go @@ -15,16 +15,21 @@ const ReleaseAPIBasePath = "/releases" // Release is a placeholder for now. type Release struct { Name string `json:"name,omitempty"` - Slug string `json:"slug"` + Slug string `json:"slug,omitempty"` ServiceType string `json:"service_type,omitempty"` Version string `json:"version,omitempty"` MultiTenant bool `json:"multitenant,omitempty"` + + // A URI to retrieve more information about this Release. + URI string `json:"uri,omitempty"` + // PackageName is the package name of the release. + PackageName string `json:"package_name,omitempty"` } // ReleasesResultList is a wrapper around a slice of // Releases for json unmarshaling. type ReleasesResultList struct { - Releases []Release `json:"releases"` + Releases []Release `json:"releases,omitempty"` } // ReleaseClient is a client for the Releases API. diff --git a/bonsai/space.go b/bonsai/space.go index 8d2cf2c..1f0e82f 100644 --- a/bonsai/space.go +++ b/bonsai/space.go @@ -26,13 +26,18 @@ type CloudProvider struct { type Space struct { Path string `json:"path"` PrivateNetwork bool `json:"private_network"` - Cloud CloudProvider `json:"cloud"` + Cloud CloudProvider `json:"cloud,omitempty"` + + // The geographic region in which the cluster is running. + Region string `json:"region,omitempty"` + // A URI to retrieve more information about this Release. + URI string `json:"uri,omitempty"` } // SpacesResultList is a wrapper around a slice of // Spaces for json unmarshaling. type SpacesResultList struct { - Spaces []Space `json:"spaces"` + Spaces []Space `json:"spaces,omitempty"` } // SpaceClient is a client for the Spaces API. diff --git a/go.mod b/go.mod index bae3410..901e977 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/omc/bonsai-api-go/v1 go 1.22 require ( + github.com/go-chi/chi/v5 v5.0.12 + github.com/google/go-querystring v1.1.0 github.com/hetznercloud/hcloud-go/v2 v2.7.2 github.com/stretchr/testify v1.9.0 golang.org/x/net v0.24.0 diff --git a/go.sum b/go.sum index 35f771d..56ffe21 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,13 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/hetznercloud/hcloud-go/v2 v2.7.2 h1:UlE7n1GQZacCfyjv9tDVUN7HZfOXErPIfM/M039u9A0= github.com/hetznercloud/hcloud-go/v2 v2.7.2/go.mod h1:49tIV+pXRJTUC7fbFZ03s45LKqSQdOPP5y91eOnJo/k= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -34,6 +39,7 @@ golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=