diff --git a/CODEOWNERS b/CODEOWNERS index e4e881c..e65baa1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 601993e..6bd6702 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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" @@ -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{ @@ -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(), }, } @@ -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 } diff --git a/internal/provider/stack/stack.go b/internal/provider/stack/stack.go new file mode 100644 index 0000000..fb9f71f --- /dev/null +++ b/internal/provider/stack/stack.go @@ -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 +} diff --git a/internal/scylla/client.go b/internal/scylla/client.go index eefe85c..b149022 100644 --- a/internal/scylla/client.go +++ b/internal/scylla/client.go @@ -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 ( @@ -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 @@ -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) { diff --git a/internal/scylla/model/model.go b/internal/scylla/model/model.go index 09574c5..05b1d09 100644 --- a/internal/scylla/model/model.go +++ b/internal/scylla/model/model.go @@ -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"` +} diff --git a/internal/scylla/v2/options.go b/internal/scylla/v2/options.go new file mode 100644 index 0000000..0bbc26e --- /dev/null +++ b/internal/scylla/v2/options.go @@ -0,0 +1,85 @@ +package scylla + +import ( + "net/http" + "net/http/cookiejar" + "net/url" + "path" + + "github.com/eapache/go-resiliency/retrier" + "golang.org/x/net/publicsuffix" +) + +var globalCookieJar *cookiejar.Jar + +func init() { + var err error + globalCookieJar, err = cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { + panic("unexpected error: " + err.Error()) + } +} + +func WithRetryPolicy(r *retrier.Retrier) func(*Client) { + return func(c *Client) { + c.retry = r + } +} + +func WithUserAgent(s string) func(*Client) { + return func(c *Client) { + c.reqmw = append(c.reqmw, func(r *http.Request) { + r.Header.Set("User-Agent", s) + }) + } +} + +func WithGlobalCookieJar() func(*Client) { + return func(c *Client) { + c.client.Jar = globalCookieJar + } +} + +func WithCookieJar() func(*Client) { + return func(c *Client) { + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { + panic("unexpected error: " + err.Error()) + } + c.client.Jar = jar + } +} + +func WithBaseURL(s string) func(*Client) { + return func(c *Client) { + if s == "" { + return + } + u, err := url.Parse(s) + if err != nil { + panic("unexpected error: " + err.Error()) + } + c.reqmw = append(c.reqmw, func(r *http.Request) { + if u.Scheme != "" { + r.URL.Scheme = u.Scheme + } + if u.Host != "" { + r.URL.Host = u.Host + } + if u.Path != "" { + r.URL.Path = path.Join("/", u.Path, r.URL.Path) + } + if q := u.Query(); len(q) != 0 { + orig := r.URL.Query() + for k, v := range q { + orig[k] = v + } + r.URL.RawQuery = orig.Encode() + } + }) + } +} diff --git a/internal/scylla/v2/scylla.go b/internal/scylla/v2/scylla.go new file mode 100644 index 0000000..ce122ac --- /dev/null +++ b/internal/scylla/v2/scylla.go @@ -0,0 +1,163 @@ +package scylla + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/eapache/go-resiliency/retrier" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type Client struct { + reqmw []func(*http.Request) + client *http.Client + retry *retrier.Retrier +} + +func New(opts ...func(*Client)) *Client { + return (&Client{ + client: &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + }).With(opts...) +} + +func (c *Client) With(opts ...func(*Client)) *Client { + for _, opt := range opts { + opt(c) + } + return c +} + +func (c *Client) Request(ctx context.Context, method string, payload interface{}, format string, args ...interface{}) *http.Request { + var body io.Reader + if payload != nil { + p, err := json.Marshal(payload) + if err != nil { + panic("unexpected error marshaling payload: " + err.Error()) + } + body = bytes.NewReader(p) + } + + req, err := http.NewRequestWithContext(ctx, method, buildURL(format, args...), body) + if err != nil { + panic("unexpected error creating request: " + err.Error()) + } + + for _, mw := range c.reqmw { + mw(req) + } + + req.Header.Set("Accept", "application/json") + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req +} + +func (c *Client) Do(req *http.Request, out interface{}) (*http.Response, error) { + var ( + resp *http.Response + attempt int + ) + + err := c.retry.RunCtx(req.Context(), func(ctx context.Context) (err error) { + if attempt++; attempt > 1 { + if req.Body != http.NoBody && req.GetBody != nil { + req.Body, err = req.GetBody() + if err != nil { + return fmt.Errorf("failed to get request body: %w", err) + } + } + } + + tflog.Trace(ctx, "api call", map[string]interface{}{ + "attempt": attempt, + "method": req.Method, + "url": req.URL.String(), + }) + + resp, err = c.client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + + if resp.StatusCode >= 300 { + p, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, p) + } + + if out == nil { + return nil + } + + if p, ok := out.(*[]byte); ok { + *p, err = io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading body failed: %w", err) + } + return nil + } + + return json.NewDecoder(resp.Body).Decode(out) + }) + + return resp, err +} + +func (c *Client) BasicSign(req *http.Request, user string, secret []byte) error { + mac := hmac.New(sha256.New, secret) + + if req.Body == nil || req.Body == http.NoBody { + return errors.New("request body is empty") + } + + if req.GetBody == nil { + return errors.New("GetBody is nil, unable to rewind") + } + + n, err := io.Copy(mac, req.Body) + if n == 0 { + return fmt.Errorf("error signing request body: body is empty") + } + if err != nil { + return fmt.Errorf("error signing request body: %w", err) + } + + digest := "v1." + hex.EncodeToString(mac.Sum(nil)) + + req.SetBasicAuth(user, digest) + + req.Body, err = req.GetBody() + if err != nil { + return fmt.Errorf("error rewinding request body: %w", err) + } + + return nil +} + +func buildURL(format string, args ...any) string { + u, err := url.Parse(fmt.Sprintf(format, args...)) + if err != nil { + panic("unexpected error creating request: " + err.Error()) + } + u.Path = strings.TrimRight(path.Join("/", u.Path), "/") + return u.String() +}