Skip to content

Commit

Permalink
provider/stack: add new stack resource for Stacks API integration
Browse files Browse the repository at this point in the history
This PR adds a "scylladbcloud_stack" resource which is used for
accounting managed resources with the Stacks API.

It uses new client, as the payloads for Stacks API are HMAC
signed.
  • Loading branch information
rjeczalik committed Jul 11, 2024
1 parent aca8656 commit 5dcb717
Show file tree
Hide file tree
Showing 7 changed files with 435 additions and 49 deletions.
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/internal/provider/scylla/ @rjeczalik
/internal/provider/stack/ @rjeczalik
/internal/provider/allowlistrule/ @rjeczalik
/internal/provider/cluster/ @rjeczalik @charconstpointer @ksinica
/internal/provider/cqlauth/ @rjeczalik
Expand Down
31 changes: 11 additions & 20 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,17 @@ package provider

import (
"context"
"net/url"
"os"
"runtime"
"strconv"

"github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/allowlistrule"
"github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/cluster"
"github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/connection"
"github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/cqlauth"
"github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/serverless"
"github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/stack"
"github.com/scylladb/terraform-provider-scylladbcloud/internal/provider/vpcpeering"
"github.com/scylladb/terraform-provider-scylladbcloud/internal/tfcontext"

"github.com/scylladb/terraform-provider-scylladbcloud/internal/scylla"

"github.com/hashicorp/go-cty/cty"
Expand All @@ -31,6 +30,12 @@ func envEndpoint() string {
return os.Getenv("SCYLLADB_CLOUD_ENDPOINT")
}

func ignoreMeta() bool {
s := os.Getenv("SCYLLADB_CLOUD_IGNORE_META")
ok, _ := strconv.ParseBool(s)
return ok
}

func New(context.Context) (*schema.Provider, error) {
p := &schema.Provider{
Schema: map[string]*schema.Schema{
Expand Down Expand Up @@ -70,6 +75,7 @@ func New(context.Context) (*schema.Provider, error) {
"scylladbcloud_vpc_peering": vpcpeering.ResourceVPCPeering(),
"scylladbcloud_serverless_cluster": serverless.ResourceServerlessCluster(),
"scylladbcloud_cluster_connection": connection.ResourceClusterConnection(),
"scylladbcloud_stack": stack.ResourceStack(),
},
}

Expand All @@ -84,29 +90,14 @@ func configure(ctx context.Context, p *schema.Provider, d *schema.ResourceData)
var (
endpoint = d.Get("endpoint").(string)
token = d.Get("token").(string)
ignore = ignoreMeta()
)

c, err := scylla.NewClient()
c, err := scylla.NewClient(endpoint, token, userAgent(p.TerraformVersion), ignore)
if err != nil {
return nil, diag.Errorf("could not create new Scylla client: %s", err)
}

ctx = tfcontext.AddProviderInfo(ctx, endpoint)
if c.Endpoint, err = url.Parse(endpoint); err != nil {
return nil, diag.FromErr(err)
}

if c.Meta, err = scylla.BuildCloudmeta(ctx, c); err != nil {
return nil, diag.Errorf("could not build Cloudmeta: %s", err)
}

c.Headers.Set("Accept", "application/json; charset=utf-8")
c.Headers.Set("User-Agent", userAgent(p.TerraformVersion))

if err := c.Auth(ctx, token); err != nil {
return nil, diag.FromErr(err)
}

return c, nil
}

Expand Down
128 changes: 128 additions & 0 deletions internal/provider/stack/stack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package stack

import (
"context"
"errors"
"fmt"
"strings"
"time"

"github.com/scylladb/terraform-provider-scylladbcloud/internal/scylla"
"github.com/scylladb/terraform-provider-scylladbcloud/internal/scylla/model"

"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

const (
stackTimeout = 60 * time.Second
)

func ResourceStack() *schema.Resource {
return &schema.Resource{
CreateContext: resourceStackCreate,
ReadContext: resourceStackRead,
UpdateContext: resourceStackUpdate,
DeleteContext: resourceStackDelete,

Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},

Timeouts: &schema.ResourceTimeout{
Create: schema.DefaultTimeout(stackTimeout),
Update: schema.DefaultTimeout(stackTimeout),
Delete: schema.DefaultTimeout(stackTimeout),
},

Schema: map[string]*schema.Schema{
"attributes": {
Description: "List of managed resources",
Required: true,
Type: schema.TypeMap,
Elem: &schema.Schema{
Type: schema.TypeString,
},
},
},
}
}

func resourceStackCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
var (
c = meta.(*scylla.Client)
s = &model.Stack{
RequestType: "Create",
ResourceProperties: d.Get("attributes").(map[string]interface{}),
}
)

id, err := sendStack(ctx, c, s)
if err != nil {
return diag.Errorf("failed to create stack: %s", err)
}

d.SetId(id)

return nil
}

func resourceStackRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
return nil
}

func resourceStackUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
var (
c = meta.(*scylla.Client)
s = &model.Stack{
RequestType: "Update",
ResourceProperties: d.Get("attributes").(map[string]interface{}),
}
)

_, err := sendStack(ctx, c, s)
if err != nil {
return diag.Errorf("failed to update stack: %s", err)
}

return nil
}

func resourceStackDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
var (
c = meta.(*scylla.Client)
s = &model.Stack{
RequestType: "Delete",
ResourceProperties: d.Get("attributes").(map[string]interface{}),
}
)

_, err := sendStack(ctx, c, s)
if err != nil {
return diag.Errorf("failed to delete stack: %s", err)
}

return nil
}

func sendStack(ctx context.Context, c *scylla.Client, s *model.Stack) (string, error) {
auth := strings.Split(c.Token, ":")

if len(auth) != 2 {
return "", errors.New("invalid token format")
}

req := c.V2.Request(ctx, "POST", s, "/")

req.Header.Set("X-Scylla-Cloud-Stack-Flavor", "tf")

if err := c.V2.BasicSign(req, auth[0], []byte(auth[1])); err != nil {
return "", fmt.Errorf("failed to sign request: %w", err)
}

if _, err := c.V2.Do(req, s); err != nil {
return "", fmt.Errorf("failed to create stack: %w", err)
}

return auth[0], nil
}
70 changes: 41 additions & 29 deletions internal/scylla/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import (
stdpath "path"
"time"

"github.com/hashicorp/terraform-plugin-log/tflog"
v2scylla "github.com/scylladb/terraform-provider-scylladbcloud/internal/scylla/v2"
"github.com/scylladb/terraform-provider-scylladbcloud/internal/tfcontext"

"github.com/eapache/go-resiliency/retrier"
"github.com/hashicorp/terraform-plugin-log/tflog"
)

const (
Expand All @@ -34,6 +35,9 @@ type Client struct {
// API endpoint
Endpoint *url.URL

// Token is the API token used to authenticate requests.
Token string

// ErrCodes provides a human-readable translation of ScyllaDB API errors.
ErrCodes map[string]string // code -> message

Expand All @@ -44,52 +48,60 @@ type Client struct {
// AccountID holds the account ID used in requests to the API.
AccountID int64

Retry *retrier.Retrier // Retrier is used to retry requests to the API.
// Retry is used to retry requests to the API.
Retry *retrier.Retrier

// V2 is the client to call the V2 API, it does not require costly
// metadata building.
V2 *v2scylla.Client
}

func NewClient() (*Client, error) {
func NewClient(endpoint, token, useragent string, ignoreMeta bool) (*Client, error) {
errCodes, err := parse(codes, codesDelim, codesFunc)
if err != nil {
return nil, fmt.Errorf("failed to parse error codes: %w", err)
}

end, err := url.Parse(endpoint)
if err != nil {
return nil, err
}

ctx := context.Background()
retry := retrier.New(
retrier.ExponentialBackoff(5, 5*time.Second),
DefaultClassifier,
)

c := &Client{
Token: token,
ErrCodes: errCodes,
Headers: make(http.Header),
HTTPClient: http.DefaultClient,
Retry: retrier.New(
retrier.ExponentialBackoff(5, 5*time.Second),
DefaultClassifier,
HTTPClient: &http.Client{Timeout: defaultTimeout},
Retry: retry,
Endpoint: end,
V2: v2scylla.New(
v2scylla.WithRetryPolicy(retry),
v2scylla.WithUserAgent(useragent),
v2scylla.WithBaseURL(endpoint),
v2scylla.WithGlobalCookieJar(),
),
}

return c, nil
}

// NewClient represents a new client to call the API
func (c *Client) Auth(ctx context.Context, token string) error {
if c.HTTPClient == nil {
c.HTTPClient = &http.Client{Timeout: defaultTimeout}
}

if c.Headers == nil {
c.Headers = make(http.Header)
}

c.Headers.Set("Authorization", "Bearer "+token)
c.Headers.Set("Authorization", "Bearer "+c.Token)
c.Headers.Set("Accept", "application/json; charset=utf-8")
c.Headers.Set("User-Agent", useragent)

if c.Meta == nil {
var err error
if !ignoreMeta {
if c.Meta, err = BuildCloudmeta(ctx, c); err != nil {
return fmt.Errorf("error building metadata: %w", err)
return nil, fmt.Errorf("error building metadata: %w", err)
}
if err = c.findAndSaveAccountID(ctx); err != nil {
return nil, err
}
}

if err := c.findAndSaveAccountID(ctx); err != nil {
return err
}

return nil
return c, nil
}

func (c *Client) newHttpRequest(ctx context.Context, method, path string, reqBody interface{}, query ...string) (*http.Request, error) {
Expand Down
6 changes: 6 additions & 0 deletions internal/scylla/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,9 @@ func NodesDNSNames(n []Node) []string {
type Datacenters struct {
Datacenters []Datacenter `json:"dataCenters"`
}

type Stack struct {
RequestType string `json:"RequestType"`
RequestID string `json:"RequestId"`
ResourceProperties map[string]any `json:"ResourceProperties"`
}
Loading

0 comments on commit 5dcb717

Please sign in to comment.