From dd6cd2d747057b938b5f0a2ad12fb927c1e58bf1 Mon Sep 17 00:00:00 2001 From: Martin Ohmann Date: Tue, 25 May 2021 16:25:14 +0200 Subject: [PATCH 1/2] feat: add spinnaker_pipeline_template_v2 resource Notable changes: - add `ResponseError` to make working with API errors easier - add validation to the `template` field of the new `spinnaker_pipeline_template_v2` resource to catch common issues on `terraform plan` - tests for error impl and parsing/validation logic More tests require general changes to the way the API client is instantiated and passed around. This is out of scope here and will be addressed in a separate change. --- docs/resources/pipeline_template_v2.md | 31 +++ go.mod | 3 + spinnaker/api/errors/errors.go | 68 ++++- spinnaker/api/errors/errors_test.go | 27 ++ spinnaker/api/models.go | 33 +++ .../api/{template.go => pipeline_template.go} | 0 spinnaker/api/pipeline_template_v2.go | 97 +++++++ spinnaker/provider.go | 1 + spinnaker/resource_pipeline_template_v2.go | 240 ++++++++++++++++++ .../resource_pipeline_template_v2_test.go | 44 ++++ 10 files changed, 541 insertions(+), 3 deletions(-) create mode 100644 docs/resources/pipeline_template_v2.md create mode 100644 spinnaker/api/errors/errors_test.go create mode 100644 spinnaker/api/models.go rename spinnaker/api/{template.go => pipeline_template.go} (100%) create mode 100644 spinnaker/api/pipeline_template_v2.go create mode 100644 spinnaker/resource_pipeline_template_v2.go create mode 100644 spinnaker/resource_pipeline_template_v2_test.go diff --git a/docs/resources/pipeline_template_v2.md b/docs/resources/pipeline_template_v2.md new file mode 100644 index 0000000..0887985 --- /dev/null +++ b/docs/resources/pipeline_template_v2.md @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "spinnaker_pipeline_template_v2 Resource - terraform-provider-spinnaker" +subcategory: "" +description: |- + Provides a V2 pipeline template. See https://spinnaker.io/reference/pipeline/templates/ for more details. +--- + +# spinnaker_pipeline_template_v2 (Resource) + +Provides a V2 pipeline template. See https://spinnaker.io/reference/pipeline/templates/ for more details. + + + + +## Schema + +### Required + +- **template** (String) JSON schema of the V2 pipeline template. +- **template_id** (String) ID of the template. + +### Optional + +- **id** (String) The ID of this resource. + +### Read-Only + +- **reference** (String) The URL for referencing the template in a pipeline instance. + + diff --git a/go.mod b/go.mod index 7d95ef1..b98bd97 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,12 @@ module github.com/Bonial-International-GmbH/terraform-provider-spinnaker go 1.16 require ( + github.com/antihax/optional v1.0.0 github.com/cenkalti/backoff/v4 v4.1.0 github.com/ghodss/yaml v1.0.0 + github.com/hashicorp/go-multierror v1.0.0 github.com/hashicorp/terraform-plugin-sdk v1.17.2 github.com/mitchellh/mapstructure v1.4.1 github.com/spinnaker/spin v1.22.0 + github.com/stretchr/testify v1.7.0 ) diff --git a/spinnaker/api/errors/errors.go b/spinnaker/api/errors/errors.go index bb9d61a..681e148 100644 --- a/spinnaker/api/errors/errors.go +++ b/spinnaker/api/errors/errors.go @@ -1,11 +1,16 @@ package errors -import "regexp" +import ( + "errors" + "fmt" + "net/http" + "regexp" -var ( - pipelineAlreadyExistsRegexp = regexp.MustCompile(`.*A pipeline with name .* already exists.*`) + gateapi "github.com/spinnaker/spin/gateapi" ) +var pipelineAlreadyExistsRegexp = regexp.MustCompile(`.*A pipeline with name .* already exists.*`) + // IsPipelineAlreadyExists returns true if the error indicates that a pipeline // already exists. func IsPipelineAlreadyExists(err error) bool { @@ -15,3 +20,60 @@ func IsPipelineAlreadyExists(err error) bool { return pipelineAlreadyExistsRegexp.MatchString(err.Error()) } + +// IsNotFound returns true if err resembles an HTTP NotFound error. +func IsNotFound(err error) bool { + return HasCode(http.StatusNotFound, err) +} + +// HasCode returns true if err resembles an HTTP error with status code. +func HasCode(code int, err error) bool { + var respErr *ResponseError + + if errors.As(err, &respErr) { + return respErr.Code() == code + } + + return false +} + +// ResponseError wraps a (potentially nil) *http.Response and an error. +type ResponseError struct { + resp *http.Response + err error +} + +// NewResponseError creates a new *ResponseError. +func NewResponseError(resp *http.Response, err error) *ResponseError { + return &ResponseError{ + resp: resp, + err: err, + } +} + +// Code returns the HTTP status code if the error includes an *http.Response. +// Otherwise returns 0. +func (e *ResponseError) Code() int { + if e.resp != nil { + return e.resp.StatusCode + } + + return 0 +} + +// Error implements the error interface. +func (e *ResponseError) Error() string { + if e.resp == nil { + return e.err.Error() + } + + code := e.Code() + + var gateErr gateapi.GenericSwaggerError + + if errors.As(e.err, &gateErr) { + return fmt.Sprintf("%v, Code: %d, Body: %s", gateErr, code, string(gateErr.Body())) + } + + return fmt.Sprintf("%v, Code: %d", e.err, code) +} diff --git a/spinnaker/api/errors/errors_test.go b/spinnaker/api/errors/errors_test.go new file mode 100644 index 0000000..a96a445 --- /dev/null +++ b/spinnaker/api/errors/errors_test.go @@ -0,0 +1,27 @@ +package errors + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResponseError(t *testing.T) { + err := errors.New("the-error") + notFoundResp := &http.Response{StatusCode: http.StatusNotFound} + + require.EqualError(t, NewResponseError(nil, err), "the-error") + require.EqualError(t, NewResponseError(notFoundResp, err), "the-error, Code: 404") +} + +func TestIsNotFound(t *testing.T) { + err := errors.New("the-error") + respErr := NewResponseError(nil, err) + notFoundErr := NewResponseError(&http.Response{StatusCode: http.StatusNotFound}, err) + + require.False(t, IsNotFound(err)) + require.False(t, IsNotFound(respErr)) + require.True(t, IsNotFound(notFoundErr)) +} diff --git a/spinnaker/api/models.go b/spinnaker/api/models.go new file mode 100644 index 0000000..77b1504 --- /dev/null +++ b/spinnaker/api/models.go @@ -0,0 +1,33 @@ +package api + +// PipelineTemplateV2 defines the schema of the pipeline template JSON. +type PipelineTemplateV2 struct { + ID string `json:"id,omitempty"` + Metadata PipelineTemplateV2Metadata `json:"metadata"` + Pipeline interface{} `json:"pipeline"` + Protect *bool `json:"protect,omitempty"` + Schema string `json:"schema"` + Variables []PipelineTemplateV2Variable `json:"variables,omitempty"` +} + +type PipelineTemplateV2Metadata struct { + Name string `json:"name"` + Description string `json:"description"` + Owner *string `json:"owner,omitempty"` + Scopes []string `json:"scopes,omitempty"` +} + +type PipelineTemplateV2Variable struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + DefaultValue interface{} `json:"defaultValue,omitempty"` + Type string `json:"type"` + Group *string `json:"group,omitempty"` + Example *string `json:"example,omitempty"` +} + +type PipelineTemplateV2Version struct { + ID string `json:"id"` + Digest string `json:"digest"` + Tag string `json:"tag"` +} diff --git a/spinnaker/api/template.go b/spinnaker/api/pipeline_template.go similarity index 100% rename from spinnaker/api/template.go rename to spinnaker/api/pipeline_template.go diff --git a/spinnaker/api/pipeline_template_v2.go b/spinnaker/api/pipeline_template_v2.go new file mode 100644 index 0000000..1744bcb --- /dev/null +++ b/spinnaker/api/pipeline_template_v2.go @@ -0,0 +1,97 @@ +package api + +import ( + "net/http" + + "github.com/Bonial-International-GmbH/terraform-provider-spinnaker/spinnaker/api/errors" + "github.com/antihax/optional" + "github.com/mitchellh/mapstructure" + gate "github.com/spinnaker/spin/cmd/gateclient" + gateapi "github.com/spinnaker/spin/gateapi" +) + +// CreatePipelineTemplateV2 creates a pipeline template. +func CreatePipelineTemplateV2(client *gate.GatewayClient, template *PipelineTemplateV2) error { + _, resp, err := retry(func() (map[string]interface{}, *http.Response, error) { + return client.V2PipelineTemplatesControllerApi.CreateUsingPOST1(client.Context, template, nil) + }) + if err != nil || (resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated) { + return errors.NewResponseError(resp, err) + } + + return nil +} + +// GetPipelineTemplateV2 fetches the pipeline template with templateID. +func GetPipelineTemplateV2(client *gate.GatewayClient, templateID string) (*PipelineTemplateV2, error) { + payload, resp, err := retry(func() (map[string]interface{}, *http.Response, error) { + return client.V2PipelineTemplatesControllerApi.GetUsingGET2(client.Context, templateID, nil) + }) + if err != nil || resp.StatusCode != http.StatusOK { + return nil, errors.NewResponseError(resp, err) + } + + var template PipelineTemplateV2 + + if err := mapstructure.Decode(payload, &template); err != nil { + return nil, err + } + + return &template, nil +} + +// DeletePipelineTemplateV2 deletes the pipeline template with templateID. +// Either digest or tag can be set on a delete request, but not both. +func DeletePipelineTemplateV2(client *gate.GatewayClient, templateID, tag, digest string) error { + opts := &gateapi.V2PipelineTemplatesControllerApiDeleteUsingDELETE1Opts{} + if digest != "" { + opts.Digest = optional.NewString(digest) + } else if tag != "" { + opts.Tag = optional.NewString(tag) + } + + _, resp, err := retry(func() (map[string]interface{}, *http.Response, error) { + return client.V2PipelineTemplatesControllerApi.DeleteUsingDELETE1(client.Context, templateID, opts) + }) + if err != nil || (resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent) { + return errors.NewResponseError(resp, err) + } + + return nil +} + +// UpdatePipelineTemplateV2 updates the pipeline template with templateID with +// the data in template. +func UpdatePipelineTemplateV2(client *gate.GatewayClient, template *PipelineTemplateV2) error { + _, resp, err := retry(func() (map[string]interface{}, *http.Response, error) { + return client.V2PipelineTemplatesControllerApi.UpdateUsingPOST1(client.Context, template.ID, template, nil) + }) + if err != nil || (resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated) { + return errors.NewResponseError(resp, err) + } + + return nil +} + +// ListPipelineTemplateV2Versions lists versions of all available pipeline +// templates. The resulting map is keyed by template ID. +func ListPipelineTemplateV2Versions(client *gate.GatewayClient) (map[string][]*PipelineTemplateV2Version, error) { + var payload interface{} + + _, resp, err := retry(func() (map[string]interface{}, *http.Response, error) { + v, resp, err := client.V2PipelineTemplatesControllerApi.ListVersionsUsingGET(client.Context, nil) + payload = v + return nil, resp, err + }) + if err != nil || resp.StatusCode != http.StatusOK { + return nil, errors.NewResponseError(resp, err) + } + + var versionMap map[string][]*PipelineTemplateV2Version + + if err = mapstructure.Decode(payload, &versionMap); err != nil { + return nil, err + } + + return versionMap, nil +} diff --git a/spinnaker/provider.go b/spinnaker/provider.go index e116585..004a626 100644 --- a/spinnaker/provider.go +++ b/spinnaker/provider.go @@ -42,6 +42,7 @@ func Provider() *schema.Provider { "spinnaker_pipeline": resourcePipeline(), "spinnaker_pipeline_template": resourcePipelineTemplate(), "spinnaker_pipeline_template_config": resourcePipelineTemplateConfig(), + "spinnaker_pipeline_template_v2": resourcePipelineTemplateV2(), }, DataSourcesMap: map[string]*schema.Resource{ "spinnaker_pipeline": datasourcePipeline(), diff --git a/spinnaker/resource_pipeline_template_v2.go b/spinnaker/resource_pipeline_template_v2.go new file mode 100644 index 0000000..6b1f42f --- /dev/null +++ b/spinnaker/resource_pipeline_template_v2.go @@ -0,0 +1,240 @@ +package spinnaker + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/Bonial-International-GmbH/terraform-provider-spinnaker/spinnaker/api" + apierrors "github.com/Bonial-International-GmbH/terraform-provider-spinnaker/spinnaker/api/errors" + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func resourcePipelineTemplateV2() *schema.Resource { + return &schema.Resource{ + Description: "Provides a V2 pipeline template. See https://spinnaker.io/reference/pipeline/templates/ for more details.", + Schema: map[string]*schema.Schema{ + "template": { + Description: "JSON schema of the V2 pipeline template.", + Type: schema.TypeString, + Required: true, + ValidateFunc: func(val interface{}, key string) (warns []string, errs []error) { + if _, err := parsePipelineTemplateV2(val.(string)); err != nil { + errs = append(errs, fmt.Errorf("invalid pipeline template: %w", err)) + } + return + }, + DiffSuppressFunc: func(key, old, new string, d *schema.ResourceData) bool { + equal, _ := areEqualJSON(old, new) + return equal + }, + }, + "template_id": { + Description: "ID of the template.", + Type: schema.TypeString, + ForceNew: true, + Required: true, + }, + "reference": { + Description: "The URL for referencing the template in a pipeline instance.", + Type: schema.TypeString, + Computed: true, + }, + }, + Create: resourcePipelineTemplateV2Create, + Read: resourcePipelineTemplateV2Read, + Update: resourcePipelineTemplateV2Update, + Delete: resourcePipelineTemplateV2Delete, + Exists: resourcePipelineTemplateV2Exists, + } +} + +func resourcePipelineTemplateV2Create(data *schema.ResourceData, meta interface{}) error { + clientConfig := meta.(*clientConfig) + + client, err := clientConfig.Client() + if err != nil { + return err + } + + rawTemplate := data.Get("template").(string) + + template, err := parsePipelineTemplateV2(rawTemplate) + if err != nil { + return err + } + + template.ID = data.Get("template_id").(string) + + if err := api.CreatePipelineTemplateV2(client, template); err != nil { + return fmt.Errorf("failed to create pipeline template: %w", err) + } + + data.SetId(template.ID) + + return resourcePipelineTemplateV2Read(data, meta) +} + +func resourcePipelineTemplateV2Read(data *schema.ResourceData, meta interface{}) error { + clientConfig := meta.(*clientConfig) + + client, err := clientConfig.Client() + if err != nil { + return err + } + + templateID := data.Get("template_id").(string) + + template, err := api.GetPipelineTemplateV2(client, templateID) + if err != nil { + return fmt.Errorf("failed to fetch pipeline template %q: %w", templateID, err) + } + + // Unset template ID before marshalling so it does not cause a diff in the + // template field. + template.ID = "" + + rawTemplate, err := json.Marshal(template) + if err != nil { + return err + } + + reference := fmt.Sprintf("spinnaker://%s", templateID) + + data.Set("template", string(rawTemplate)) + data.Set("template_id", templateID) + data.Set("reference", reference) + data.SetId(templateID) + + return nil +} + +func resourcePipelineTemplateV2Update(data *schema.ResourceData, meta interface{}) error { + clientConfig := meta.(*clientConfig) + + client, err := clientConfig.Client() + if err != nil { + return err + } + + rawTemplate := data.Get("template").(string) + + template, err := parsePipelineTemplateV2(rawTemplate) + if err != nil { + return err + } + + template.ID = data.Get("template_id").(string) + + if err := api.UpdatePipelineTemplateV2(client, template); err != nil { + return fmt.Errorf("failed to update pipeline template %q: %w", template.ID, err) + } + + data.SetId(template.ID) + + return resourcePipelineTemplateV2Read(data, meta) +} + +func resourcePipelineTemplateV2Delete(data *schema.ResourceData, meta interface{}) error { + clientConfig := meta.(*clientConfig) + + client, err := clientConfig.Client() + if err != nil { + return err + } + + templateID := data.Get("template_id").(string) + + versionMap, err := api.ListPipelineTemplateV2Versions(client) + if err != nil { + return fmt.Errorf("failed to list pipeline template versions: %w", err) + } + + // Delete all versions for this pipeline template. + for _, version := range versionMap[templateID] { + err := api.DeletePipelineTemplateV2(client, version.ID, version.Tag, version.Digest) + if err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete pipeline template %q (tag: %q, digest: %q): %w", + version.ID, version.Tag, version.Digest, err) + } + } + + data.SetId("") + + return nil +} + +func resourcePipelineTemplateV2Exists(data *schema.ResourceData, meta interface{}) (bool, error) { + clientConfig := meta.(*clientConfig) + + client, err := clientConfig.Client() + if err != nil { + return false, err + } + + templateID := data.Get("template_id").(string) + + _, err = api.GetPipelineTemplateV2(client, templateID) + if apierrors.IsNotFound(err) { + return false, nil + } else if err != nil { + return false, fmt.Errorf("failed to fetch pipeline template %q: %w", templateID, err) + } + + return true, nil +} + +func parsePipelineTemplateV2(rawTemplate string) (*api.PipelineTemplateV2, error) { + var template *api.PipelineTemplateV2 + + if err := json.Unmarshal([]byte(rawTemplate), &template); err != nil { + return nil, fmt.Errorf("invalid template json: %w", err) + } + + if err := validatePipelineTemplateV2(template); err != nil { + return nil, fmt.Errorf("invalid template: %w", err) + } + + return template, nil +} + +func validatePipelineTemplateV2(template *api.PipelineTemplateV2) error { + var errs *multierror.Error + + if template.ID != "" { + errs = multierror.Append(errs, errors.New("field 'id' must not be set as they will be computed")) + } + + if template.Schema != "v2" { + errs = multierror.Append(errs, errors.New("field 'schema' must be set to 'v2'")) + } + + if template.Pipeline == nil { + errs = multierror.Append(errs, errors.New("field 'pipeline' missing")) + } + + if template.Metadata.Name == "" { + errs = multierror.Append(errs, errors.New("field 'metadata.name' must not be empty")) + } + + if template.Metadata.Description == "" { + errs = multierror.Append(errs, errors.New("field 'metadata.description' must not be empty")) + } + + if len(template.Metadata.Scopes) == 0 { + errs = multierror.Append(errs, errors.New("field 'metadata.scopes' must contain at least one scope")) + } + + for i, variable := range template.Variables { + if variable.Name == "" { + errs = multierror.Append(errs, fmt.Errorf("field 'variables[%d].name' must not be empty", i)) + } + + if variable.Type == "" { + errs = multierror.Append(errs, fmt.Errorf("field 'variables[%d].type' must not be empty", i)) + } + } + + return errs.ErrorOrNil() +} diff --git a/spinnaker/resource_pipeline_template_v2_test.go b/spinnaker/resource_pipeline_template_v2_test.go new file mode 100644 index 0000000..8bd949d --- /dev/null +++ b/spinnaker/resource_pipeline_template_v2_test.go @@ -0,0 +1,44 @@ +package spinnaker + +import ( + "testing" + + "github.com/Bonial-International-GmbH/terraform-provider-spinnaker/spinnaker/api" + "github.com/stretchr/testify/require" +) + +func TestParsePipelineTemplateV2(t *testing.T) { + t.Run("must be valid json", func(t *testing.T) { + _, err := parsePipelineTemplateV2("{invalid") + require.EqualError(t, err, `invalid template json: invalid character 'i' looking for beginning of object key string`) + }) + + t.Run("validates parsed template", func(t *testing.T) { + _, err := parsePipelineTemplateV2(`{"variables":[{}]}`) + require.Error(t, err) + require.Regexp(t, `^invalid template: \d+ errors occurred:.*`, err.Error()) + }) + + t.Run("parses valid template json", func(t *testing.T) { + template, err := parsePipelineTemplateV2(`{"schema":"v2","pipeline":{},"metadata":{"name":"bar","description":"baz","scopes":["global"]}}`) + require.NoError(t, err) + + expected := &api.PipelineTemplateV2{ + Schema: "v2", + Metadata: api.PipelineTemplateV2Metadata{ + Name: "bar", + Description: "baz", + Scopes: []string{"global"}, + }, + Pipeline: map[string]interface{}{}, + } + + require.Equal(t, expected, template) + }) + + t.Run("validates that id field is not set", func(t *testing.T) { + _, err := parsePipelineTemplateV2(`{"schema":"v2","id":"foo","pipeline":{},"metadata":{"name":"bar","description":"baz","scopes":["global"]}}`) + require.Error(t, err) + require.Regexp(t, `.*field 'id' must not be set.*`, err.Error()) + }) +} From 73251ba97d8174c63c69519be3779dc02ea868ce Mon Sep 17 00:00:00 2001 From: Martin Ohmann Date: Fri, 28 May 2021 10:59:08 +0200 Subject: [PATCH 2/2] fix: remove obsolete PipelineTemplateV2Variable fields --- spinnaker/api/models.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/spinnaker/api/models.go b/spinnaker/api/models.go index 77b1504..a5ce21b 100644 --- a/spinnaker/api/models.go +++ b/spinnaker/api/models.go @@ -22,8 +22,6 @@ type PipelineTemplateV2Variable struct { Description *string `json:"description,omitempty"` DefaultValue interface{} `json:"defaultValue,omitempty"` Type string `json:"type"` - Group *string `json:"group,omitempty"` - Example *string `json:"example,omitempty"` } type PipelineTemplateV2Version struct {