diff --git a/api/build/compile_publish.go b/api/build/compile_publish.go index cc99e8579..d355488d0 100644 --- a/api/build/compile_publish.go +++ b/api/build/compile_publish.go @@ -26,13 +26,14 @@ import ( // CompileAndPublishConfig is a struct that contains information for the CompileAndPublish function. type CompileAndPublishConfig struct { - Build *types.Build - Metadata *internal.Metadata - BaseErr string - Source string - Comment string - Labels []string - Retries int + Build *types.Build + Deployment *types.Deployment + Metadata *internal.Metadata + BaseErr string + Source string + Comment string + Labels []string + Retries int } // CompileAndPublish is a helper function to generate the queue items for a build. It takes a form @@ -307,6 +308,15 @@ func CompileAndPublish( errors.New(skip) } + // validate deployment config + if (b.GetEvent() == constants.EventDeploy) && cfg.Deployment != nil { + if err := p.Deployment.Validate(cfg.Deployment.GetTarget(), cfg.Deployment.GetPayload()); err != nil { + retErr := fmt.Errorf("%s: failed to validate deployment for %s: %w", baseErr, repo.GetFullName(), err) + + return nil, nil, http.StatusBadRequest, retErr + } + } + // check if the pipeline did not already exist in the database if pipeline == nil { pipeline = compiled diff --git a/api/deployment/get.go b/api/deployment/get.go index 5c978cf64..966456027 100644 --- a/api/deployment/get.go +++ b/api/deployment/get.go @@ -87,7 +87,7 @@ func GetDeployment(c *gin.Context) { } // send API call to database to capture the deployment - d, err := database.FromContext(c).GetDeployment(ctx, int64(number)) + d, err := database.FromContext(c).GetDeploymentForRepo(ctx, r, int64(number)) if err != nil { // send API call to SCM to capture the deployment d, err = scm.FromContext(c).GetDeployment(ctx, u, r, int64(number)) diff --git a/api/deployment/get_config.go b/api/deployment/get_config.go new file mode 100644 index 000000000..bf8323e33 --- /dev/null +++ b/api/deployment/get_config.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 + +package deployment + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "gorm.io/gorm" + + "github.com/go-vela/server/compiler" + "github.com/go-vela/server/database" + "github.com/go-vela/server/router/middleware/repo" + "github.com/go-vela/server/router/middleware/user" + "github.com/go-vela/server/scm" + "github.com/go-vela/server/util" +) + +// swagger:operation GET /api/v1/deployments/{org}/{repo}/config deployments GetDeploymentConfig +// +// Get a deployment config +// +// --- +// produces: +// - application/json +// parameters: +// - in: path +// name: org +// description: Name of the organization +// required: true +// type: string +// - in: path +// name: repo +// description: Name of the repository +// required: true +// type: string +// - in: query +// name: ref +// description: Ref to target for the deployment config +// type: string +// security: +// - ApiKeyAuth: [] +// responses: +// '200': +// description: Successfully retrieved the deployment config +// schema: +// "$ref": "#/definitions/Deployment" +// '400': +// description: Invalid request payload or path +// schema: +// "$ref": "#/definitions/Error" +// '401': +// description: Unauthorized +// schema: +// "$ref": "#/definitions/Error" +// '404': +// description: Not found +// schema: +// "$ref": "#/definitions/Error" +// '500': +// description: Unexpected server error +// schema: +// "$ref": "#/definitions/Error" + +// GetDeploymentConfig represents the API handler to get a deployment config at a given ref. +func GetDeploymentConfig(c *gin.Context) { + // capture middleware values + l := c.MustGet("logger").(*logrus.Entry) + r := repo.Retrieve(c) + u := user.Retrieve(c) + + ctx := c.Request.Context() + + // capture ref from parameters - use default branch if not provided + ref := util.QueryParameter(c, "ref", r.GetBranch()) + + entry := fmt.Sprintf("%s@%s", r.GetFullName(), ref) + + l.Debugf("reading deployment config %s", entry) + + var config []byte + + // check if the pipeline exists in the database + p, err := database.FromContext(c).GetPipelineForRepo(ctx, ref, r) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + l.Debugf("pipeline %s not found in database, fetching from scm", entry) + + config, err = scm.FromContext(c).ConfigBackoff(ctx, u, r, ref) + if err != nil { + retErr := fmt.Errorf("unable to get pipeline configuration for %s: %w", entry, err) + + util.HandleError(c, http.StatusNotFound, retErr) + + return + } + } else { + // some other error + retErr := fmt.Errorf("unable to get pipeline for %s: %w", entry, err) + + util.HandleError(c, http.StatusInternalServerError, retErr) + + return + } + } else { + l.Debugf("pipeline %s found in database", entry) + + config = p.GetData() + } + + // set up compiler + compiler := compiler.FromContext(c).Duplicate().WithCommit(ref).WithRepo(r).WithUser(u) + + // compile the pipeline + pipeline, _, err := compiler.CompileLite(ctx, config, nil, true) + if err != nil { + retErr := fmt.Errorf("unable to compile pipeline %s: %w", entry, err) + + util.HandleError(c, http.StatusBadRequest, retErr) + + return + } + + c.JSON(http.StatusOK, pipeline.Deployment) +} diff --git a/api/webhook/post.go b/api/webhook/post.go index 6cbc17a72..52f0d90dd 100644 --- a/api/webhook/post.go +++ b/api/webhook/post.go @@ -378,13 +378,14 @@ func PostWebhook(c *gin.Context) { // construct CompileAndPublishConfig config := build.CompileAndPublishConfig{ - Build: b, - Metadata: m, - BaseErr: baseErr, - Source: "webhook", - Comment: prComment, - Labels: prLabels, - Retries: 3, + Build: b, + Deployment: webhook.Deployment, + Metadata: m, + BaseErr: baseErr, + Source: "webhook", + Comment: prComment, + Labels: prLabels, + Retries: 3, } // generate the queue item diff --git a/compiler/native/compile.go b/compiler/native/compile.go index 9e7c638df..313e24db3 100644 --- a/compiler/native/compile.go +++ b/compiler/native/compile.go @@ -131,6 +131,12 @@ func (c *client) CompileLite(ctx context.Context, v interface{}, ruleData *pipel // create map of templates for easy lookup templates := mapFromTemplates(p.Templates) + // expand deployment config + p, err = c.ExpandDeployment(ctx, p, templates) + if err != nil { + return nil, _pipeline, err + } + switch { case len(p.Stages) > 0: // inject the templates into the steps @@ -330,6 +336,12 @@ func (c *client) compileSteps(ctx context.Context, p *yaml.Build, _pipeline *api return nil, _pipeline, err } + // inject the template for deploy config if exists + p, err = c.ExpandDeployment(ctx, p, tmpls) + if err != nil { + return nil, _pipeline, err + } + // inject the templates into the steps p, err = c.ExpandSteps(ctx, p, tmpls, r, c.GetTemplateDepth()) if err != nil { diff --git a/compiler/native/compile_test.go b/compiler/native/compile_test.go index e98b13e2b..4b0214d6f 100644 --- a/compiler/native/compile_test.go +++ b/compiler/native/compile_test.go @@ -637,7 +637,7 @@ func TestNative_Compile_StepsPipeline(t *testing.T) { t.Errorf("Compile returned err: %v", err) } - if diff := cmp.Diff(got, want); diff != "" { + if diff := cmp.Diff(want, got); diff != "" { t.Errorf("Compile mismatch (-want +got):\n%s", diff) } } diff --git a/compiler/native/expand.go b/compiler/native/expand.go index aae868693..a19abcd98 100644 --- a/compiler/native/expand.go +++ b/compiler/native/expand.go @@ -220,6 +220,43 @@ func (c *client) ExpandSteps(ctx context.Context, s *yaml.Build, tmpls map[strin return s, nil } +// ExpandDeployment injects the template for a +// templated deployment config in a yaml configuration. +func (c *client) ExpandDeployment(ctx context.Context, b *yaml.Build, tmpls map[string]*yaml.Template) (*yaml.Build, error) { + if len(tmpls) == 0 { + return b, nil + } + + if len(b.Deployment.Template.Name) == 0 { + return b, nil + } + + // lookup step template name + tmpl, ok := tmpls[b.Deployment.Template.Name] + if !ok { + return b, fmt.Errorf("missing template source for template %s in pipeline for deployment config", b.Deployment.Template.Name) + } + + bytes, err := c.getTemplate(ctx, tmpl, b.Deployment.Template.Name) + if err != nil { + return b, err + } + + // initialize variable map if not parsed from config + if len(b.Deployment.Template.Variables) == 0 { + b.Deployment.Template.Variables = make(map[string]interface{}) + } + + tmplBuild, err := c.mergeDeployTemplate(bytes, tmpl, &b.Deployment) + if err != nil { + return b, err + } + + b.Deployment = tmplBuild.Deployment + + return b, nil +} + func (c *client) getTemplate(ctx context.Context, tmpl *yaml.Template, name string) ([]byte, error) { var ( bytes []byte @@ -368,6 +405,20 @@ func (c *client) mergeTemplate(bytes []byte, tmpl *yaml.Template, step *yaml.Ste } } +func (c *client) mergeDeployTemplate(bytes []byte, tmpl *yaml.Template, d *yaml.Deployment) (*yaml.Build, error) { + switch tmpl.Format { + case constants.PipelineTypeGo, "golang", "": + //nolint:lll // ignore long line length due to return + return native.Render(string(bytes), "", d.Template.Name, make(raw.StringSliceMap), d.Template.Variables) + case constants.PipelineTypeStarlark: + //nolint:lll // ignore long line length due to return + return starlark.Render(string(bytes), "", d.Template.Name, make(raw.StringSliceMap), d.Template.Variables, c.GetStarlarkExecLimit()) + default: + //nolint:lll // ignore long line length due to return + return &yaml.Build{}, fmt.Errorf("format of %s is unsupported", tmpl.Format) + } +} + // helper function that creates a map of templates from a yaml configuration. func mapFromTemplates(templates []*yaml.Template) map[string]*yaml.Template { m := make(map[string]*yaml.Template) diff --git a/compiler/native/expand_test.go b/compiler/native/expand_test.go index 8c56d77f8..412877966 100644 --- a/compiler/native/expand_test.go +++ b/compiler/native/expand_test.go @@ -391,6 +391,111 @@ func TestNative_ExpandSteps(t *testing.T) { } } +func TestNative_ExpandDeployment(t *testing.T) { + // setup context + gin.SetMode(gin.TestMode) + + resp := httptest.NewRecorder() + _, engine := gin.CreateTestContext(resp) + + // setup mock server + engine.GET("/api/v3/repos/:org/:repo/contents/:path", func(c *gin.Context) { + body, err := convertFileToGithubResponse(c.Param("path")) + if err != nil { + t.Error(err) + } + c.JSON(http.StatusOK, body) + }) + + s := httptest.NewServer(engine) + defer s.Close() + + // setup types + set := flag.NewFlagSet("test", 0) + set.Bool("github-driver", true, "doc") + set.String("github-url", s.URL, "doc") + set.String("github-token", "", "doc") + set.Int("max-template-depth", 5, "doc") + set.String("clone-image", defaultCloneImage, "doc") + c := cli.NewContext(nil, set, nil) + + testRepo := new(api.Repo) + + testRepo.SetID(1) + testRepo.SetOrg("foo") + testRepo.SetName("bar") + + tests := []struct { + name string + tmpls map[string]*yaml.Template + }{ + { + name: "GitHub", + tmpls: map[string]*yaml.Template{ + "deploy": { + Name: "deploy", + Source: "github.example.com/foo/bar/deploy_template.yml", + Type: "github", + }, + }, + }, + } + + deployCfg := yaml.Deployment{ + Template: yaml.StepTemplate{ + Name: "deploy", + Variables: map[string]interface{}{ + "regions": []string{"us-east-1", "us-west-1"}, + }, + }, + } + + wantDeployCfg := yaml.Deployment{ + Targets: []string{"dev", "prod", "stage"}, + Parameters: yaml.ParameterMap{ + "region": { + Description: "cluster region to deploy", + Type: "string", + Required: true, + Options: []string{"us-east-1", "us-west-1"}, + }, + "cluster_count": { + Description: "number of clusters to deploy to", + Type: "integer", + Required: false, + Min: 1, + Max: 10, + }, + }, + } + + // run test + compiler, err := FromCLIContext(c) + if err != nil { + t.Errorf("Creating new compiler returned err: %v", err) + } + + compiler.WithCommit("123abc456def").WithRepo(testRepo) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + build, err := compiler.ExpandDeployment( + context.Background(), + &yaml.Build{ + Deployment: deployCfg, + }, + test.tmpls) + if err != nil { + t.Errorf("ExpandDeployment_Type%s returned err: %v", test.name, err) + } + + if diff := cmp.Diff(wantDeployCfg, build.Deployment); diff != "" { + t.Errorf("ExpandDeployment()_Type%s mismatch (-want +got):\n%s", test.name, diff) + } + }) + } +} + func TestNative_ExpandStepsMulti(t *testing.T) { // setup context gin.SetMode(gin.TestMode) diff --git a/compiler/native/testdata/deploy_template.yml b/compiler/native/testdata/deploy_template.yml new file mode 100644 index 000000000..084c61482 --- /dev/null +++ b/compiler/native/testdata/deploy_template.yml @@ -0,0 +1,25 @@ +metadata: + template: true + +deployment: + targets: + - dev + - prod + - stage + + parameters: + region: + description: cluster region to deploy + required: true + type: string + options: + {{- range .regions }} + - {{ . }} + {{- end }} + + cluster_count: + description: number of clusters to deploy to + required: false + type: integer + min: 1 + max: 10 \ No newline at end of file diff --git a/compiler/native/transform.go b/compiler/native/transform.go index 2f54c41a3..ebdb6777e 100644 --- a/compiler/native/transform.go +++ b/compiler/native/transform.go @@ -151,12 +151,13 @@ func (c *client) TransformSteps(r *pipeline.RuleData, p *yaml.Build) (*pipeline. // create new executable pipeline pipeline := &pipeline.Build{ - Version: p.Version, - Metadata: *p.Metadata.ToPipeline(), - Steps: *p.Steps.ToPipeline(), - Secrets: *p.Secrets.ToPipeline(), - Services: *p.Services.ToPipeline(), - Worker: *p.Worker.ToPipeline(), + Version: p.Version, + Metadata: *p.Metadata.ToPipeline(), + Deployment: *p.Deployment.ToPipeline(), + Steps: *p.Steps.ToPipeline(), + Secrets: *p.Secrets.ToPipeline(), + Services: *p.Services.ToPipeline(), + Worker: *p.Worker.ToPipeline(), } // set the unique ID for the executable pipeline diff --git a/compiler/template/native/render.go b/compiler/template/native/render.go index 528188e0e..afc3bea51 100644 --- a/compiler/template/native/render.go +++ b/compiler/template/native/render.go @@ -57,7 +57,7 @@ func Render(tmpl string, name string, tName string, environment raw.StringSliceM config.Steps[index].Name = fmt.Sprintf("%s_%s", name, newStep.Name) } - return &types.Build{Metadata: config.Metadata, Steps: config.Steps, Secrets: config.Secrets, Services: config.Services, Environment: config.Environment, Templates: config.Templates}, nil + return &types.Build{Metadata: config.Metadata, Deployment: config.Deployment, Steps: config.Steps, Secrets: config.Secrets, Services: config.Services, Environment: config.Environment, Templates: config.Templates}, nil } // RenderBuild renders the templated build. diff --git a/compiler/types/pipeline/build.go b/compiler/types/pipeline/build.go index 195a353b5..bb47c4497 100644 --- a/compiler/types/pipeline/build.go +++ b/compiler/types/pipeline/build.go @@ -20,6 +20,7 @@ type Build struct { Metadata Metadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` Environment raw.StringSliceMap `json:"environment,omitempty" yaml:"environment,omitempty"` Worker Worker `json:"worker,omitempty" yaml:"worker,omitempty"` + Deployment Deployment `json:"deployment,omitempty" yaml:"deployment,omitempty"` Secrets SecretSlice `json:"secrets,omitempty" yaml:"secrets,omitempty"` Services ContainerSlice `json:"services,omitempty" yaml:"services,omitempty"` Stages StageSlice `json:"stages,omitempty" yaml:"stages,omitempty"` diff --git a/compiler/types/pipeline/deployment.go b/compiler/types/pipeline/deployment.go new file mode 100644 index 000000000..475946dea --- /dev/null +++ b/compiler/types/pipeline/deployment.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pipeline + +import ( + "fmt" + "slices" + "strconv" +) + +type ( + // Deployment is the pipeline representation of the deployment block for a pipeline. + // + // swagger:model PipelineDeployment + Deployment struct { + Targets []string `json:"targets,omitempty" yaml:"targets,omitempty"` + Parameters ParameterMap `json:"parameters,omitempty" yaml:"parameters,omitempty"` + } + + ParameterMap map[string]*Parameter + + // Parameters is the pipeline representation of the deploy parameters + // from a deployment block in a pipeline. + // + // swagger:model PipelineParameters + Parameter struct { + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Required bool `json:"required,omitempty" yaml:"required,omitempty"` + Options []string `json:"options,omitempty" yaml:"options,omitempty"` + Min int `json:"min,omitempty" yaml:"min,omitempty"` + Max int `json:"max,omitempty" yaml:"max,omitempty"` + } +) + +// Empty returns true if the provided deployment is empty. +func (d *Deployment) Empty() bool { + // return true if deployment is nil + if d == nil { + return true + } + + // return true if every deployment field is empty + if len(d.Targets) == 0 && + len(d.Parameters) == 0 { + return true + } + + // return false if any of the deployment fields are provided + return false +} + +// Validate checks the build ruledata and parameters against the deployment configuration. +func (d *Deployment) Validate(target string, inputParams map[string]string) error { + if d.Empty() { + return nil + } + + // validate targets + if len(d.Targets) > 0 && !slices.Contains(d.Targets, target) { + return fmt.Errorf("deployment target %s not found in deployment config targets", target) + } + + // validate params + for kConfig, vConfig := range d.Parameters { + var ( + inputStr string + ok bool + ) + // check if the parameter is required + if vConfig.Required { + // check if the parameter is provided + if inputStr, ok = inputParams[kConfig]; !ok { + return fmt.Errorf("deployment parameter %s is required", kConfig) + } + } else { + // check if the parameter is provided + if inputStr, ok = inputParams[kConfig]; !ok { + continue + } + } + + // check if the parameter is an option + if len(vConfig.Options) > 0 && len(inputStr) > 0 { + if !slices.Contains(vConfig.Options, inputStr) { + return fmt.Errorf("deployment parameter %s is not a valid option", kConfig) + } + } + + // check if the parameter is the correct type + if len(vConfig.Type) > 0 && len(inputStr) > 0 { + switch vConfig.Type { + case "integer", "int", "number": + val, err := strconv.Atoi(inputStr) + if err != nil { + return fmt.Errorf("deployment parameter %s is not an integer", kConfig) + } + + if vConfig.Max != 0 && val < vConfig.Min { + return fmt.Errorf("deployment parameter %s is less than the minimum value", kConfig) + } + + if vConfig.Max != 0 && val > vConfig.Max { + return fmt.Errorf("deployment parameter %s is greater than the maximum value", kConfig) + } + case "boolean", "bool": + if _, err := strconv.ParseBool(inputStr); err != nil { + return fmt.Errorf("deployment parameter %s is not a boolean", kConfig) + } + default: + continue + } + } + } + + return nil +} diff --git a/compiler/types/pipeline/deployment_test.go b/compiler/types/pipeline/deployment_test.go new file mode 100644 index 000000000..7f57c263c --- /dev/null +++ b/compiler/types/pipeline/deployment_test.go @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pipeline + +import ( + "testing" +) + +func TestPipeline_Deployment_Empty(t *testing.T) { + // setup tests + tests := []struct { + deployment *Deployment + want bool + }{ + { + deployment: &Deployment{Targets: []string{"foo"}}, + want: false, + }, + { + deployment: &Deployment{Parameters: ParameterMap{"foo": new(Parameter)}}, + want: false, + }, + { + deployment: new(Deployment), + want: true, + }, + { + want: true, + }, + } + + // run tests + for _, test := range tests { + got := test.deployment.Empty() + + if got != test.want { + t.Errorf("Empty is %v, want %t", got, test.want) + } + } +} + +func TestPipeline_Deployment_Validate(t *testing.T) { + // setup types + fullDeployConfig := &Deployment{ + Targets: []string{"east", "west", "north", "south"}, + Parameters: ParameterMap{ + "alpha": { + Description: "foo", + Type: "string", + Required: true, + Options: []string{"bar", "baz"}, + }, + "beta": { + Description: "bar", + Type: "string", + Required: false, + }, + "gamma": { + Description: "baz", + Type: "integer", + Required: true, + Min: -2, + Max: 2, + }, + "delta": { + Description: "qux", + Type: "boolean", + Required: false, + }, + "epsilon": { + Description: "quux", + Type: "number", + }, + }, + } + + // setup tests + tests := []struct { + inputTarget string + inputParams map[string]string + deployConfig *Deployment + wantErr bool + }{ + { // nil deployment config + inputTarget: "north", + inputParams: map[string]string{ + "alpha": "foo", + "beta": "bar", + }, + wantErr: false, + }, + { // empty deployment config + inputTarget: "north", + inputParams: map[string]string{ + "alpha": "foo", + "beta": "bar", + }, + deployConfig: new(Deployment), + wantErr: false, + }, + { // correct target and required params + inputTarget: "west", + inputParams: map[string]string{ + "alpha": "bar", + "gamma": "1", + }, + deployConfig: fullDeployConfig, + wantErr: false, + }, + { // correct target and wrong integer type for param gamma + inputTarget: "east", + inputParams: map[string]string{ + "alpha": "bar", + "beta": "test1", + "gamma": "string", + }, + deployConfig: fullDeployConfig, + wantErr: true, + }, + { // correct target and wrong boolean type for param delta + inputTarget: "south", + inputParams: map[string]string{ + "alpha": "bar", + "beta": "test2", + "gamma": "2", + "delta": "not-bool", + }, + deployConfig: fullDeployConfig, + wantErr: true, + }, + { // correct target and wrong option for param alpha + inputTarget: "south", + inputParams: map[string]string{ + "alpha": "bazzy", + "beta": "test2", + "gamma": "2", + "delta": "true", + }, + deployConfig: fullDeployConfig, + wantErr: true, + }, + { // correct target and gamma value over max + inputTarget: "north", + inputParams: map[string]string{ + "alpha": "bar", + "beta": "bar", + "gamma": "3", + }, + deployConfig: fullDeployConfig, + wantErr: true, + }, + { // correct target and gamma value under min + inputTarget: "north", + inputParams: map[string]string{ + "alpha": "baz", + "beta": "bar", + "gamma": "-3", + }, + deployConfig: fullDeployConfig, + wantErr: true, + }, + { // correct target and some number provided for epsilon param + inputTarget: "north", + inputParams: map[string]string{ + "alpha": "bar", + "beta": "bar", + "gamma": "1", + "delta": "true", + "epsilon": "42", + }, + deployConfig: fullDeployConfig, + wantErr: false, + }, + } + + // run tests + for _, test := range tests { + err := test.deployConfig.Validate(test.inputTarget, test.inputParams) + + if err != nil && !test.wantErr { + t.Errorf("Deployment.Validate returned err: %v", err) + } + + if err == nil && test.wantErr { + t.Errorf("Deployment.Validate did not return err") + } + } +} diff --git a/compiler/types/yaml/build.go b/compiler/types/yaml/build.go index 2a9f2b750..c0b719c9e 100644 --- a/compiler/types/yaml/build.go +++ b/compiler/types/yaml/build.go @@ -18,6 +18,7 @@ type Build struct { Stages StageSlice `yaml:"stages,omitempty" json:"stages,omitempty" jsonschema:"oneof_required=stages,description=Provide parallel execution instructions.\nReference: https://go-vela.github.io/docs/reference/yaml/stages/"` Steps StepSlice `yaml:"steps,omitempty" json:"steps,omitempty" jsonschema:"oneof_required=steps,description=Provide sequential execution instructions.\nReference: https://go-vela.github.io/docs/reference/yaml/steps/"` Templates TemplateSlice `yaml:"templates,omitempty" json:"templates,omitempty" jsonschema:"description=Provide the name of templates to expand.\nReference: https://go-vela.github.io/docs/reference/yaml/templates/"` + Deployment Deployment `yaml:"deployment,omitempty" json:"deployment,omitempty" jsonschema:"description=Provide deployment configuration.\nReference: https://go-vela.github.io/docs/reference/yaml/deployments/"` } // ToPipelineAPI converts the Build type to an API Pipeline type. @@ -73,6 +74,7 @@ func (b *Build) UnmarshalYAML(unmarshal func(interface{}) error) error { Stages StageSlice Steps StepSlice Templates TemplateSlice + Deployment Deployment }) // attempt to unmarshal as a build type @@ -96,6 +98,7 @@ func (b *Build) UnmarshalYAML(unmarshal func(interface{}) error) error { b.Stages = build.Stages b.Steps = build.Steps b.Templates = build.Templates + b.Deployment = build.Deployment return nil } diff --git a/compiler/types/yaml/build_test.go b/compiler/types/yaml/build_test.go index fc72abc85..bf2d341f6 100644 --- a/compiler/types/yaml/build_test.go +++ b/compiler/types/yaml/build_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/buildkite/yaml" + "github.com/google/go-cmp/cmp" api "github.com/go-vela/server/api/types" "github.com/go-vela/server/compiler/types/raw" @@ -596,6 +597,52 @@ func TestYaml_Build_UnmarshalYAML(t *testing.T) { }, }, }, + { + file: "testdata/build_with_deploy_config.yml", + want: &Build{ + Version: "1", + Metadata: Metadata{ + Template: false, + Clone: nil, + Environment: []string{"steps", "services", "secrets"}, + }, + Deployment: Deployment{ + Targets: []string{"dev", "stage", "production"}, + Parameters: ParameterMap{ + "alpha": { + Description: "primary node name", + Required: true, + Type: "string", + Options: []string{"north", "south"}, + }, + "beta": { + Description: "secondary node name", + Required: false, + Type: "string", + Options: []string{"east", "west"}, + }, + "cluster_count": { + Description: "number of clusters to deploy", + Required: false, + Type: "integer", + }, + "canary": { + Description: "deploy with canary strategy", + Required: true, + Type: "boolean", + }, + }, + }, + Steps: StepSlice{ + { + Commands: raw.StringSlice{"./deploy.sh"}, + Name: "deploy plugin", + Pull: "not_present", + Image: "awesome-plugin:latest", + }, + }, + }, + }, { file: "testdata/merge_anchor.yml", want: &Build{ @@ -679,8 +726,8 @@ func TestYaml_Build_UnmarshalYAML(t *testing.T) { t.Errorf("UnmarshalYAML returned err: %v", err) } - if !reflect.DeepEqual(got, test.want) { - t.Errorf("UnmarshalYAML is %v, want %v", got, test.want) + if diff := cmp.Diff(test.want, got); diff != "" { + t.Errorf("UnmarshalYAML mismatch (-want +got):\n%s", diff) } } } diff --git a/compiler/types/yaml/deployment.go b/compiler/types/yaml/deployment.go new file mode 100644 index 000000000..6cd3db4e6 --- /dev/null +++ b/compiler/types/yaml/deployment.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 + +package yaml + +import ( + "github.com/go-vela/server/compiler/types/pipeline" + "github.com/go-vela/server/compiler/types/raw" +) + +type ( + // Deployment is the yaml representation of a + // deployment block in a pipeline. + Deployment struct { + Targets raw.StringSlice `yaml:"targets,omitempty" json:"targets,omitempty" jsonschema:"description=List of deployment targets for the deployment block.\nReference: https://go-vela.github.io/docs/reference/yaml/deployments/#the-targets-key"` + Parameters ParameterMap `yaml:"parameters,omitempty" json:"parameters,omitempty" jsonschema:"description=List of parameters for the deployment block.\nReference: https://go-vela.github.io/docs/reference/yaml/deployments/#the-parameters-key"` + Template StepTemplate `yaml:"template,omitempty" json:"template,omitempty" jsonschema:"description=Name of template to expand in the deployment block.\nReference: https://go-vela.github.io/docs/reference/yaml/deployments/#the-template-key"` + } + + // ParameterMap is the yaml representation + // of the parameters block for a deployment block of a pipeline. + ParameterMap map[string]*Parameter + + // Parameters is the yaml representation of the deploy parameters + // from a deployment block in a pipeline. + Parameter struct { + Description string `yaml:"description,omitempty" json:"description,omitempty" jsonschema:"description=Description of the parameter.\nReference: https://go-vela.github.io/docs/reference/yaml/deployments/#the-parameters-key"` + Type string `yaml:"type,omitempty" json:"type,omitempty" jsonschema:"description=Type of the parameter.\nReference: https://go-vela.github.io/docs/reference/yaml/deployments/#the-parameters-key"` + Required bool `yaml:"required,omitempty" json:"required,omitempty" jsonschema:"description=Flag indicating if the parameter is required.\nReference: https://go-vela.github.io/docs/reference/yaml/deployments/#the-parameters-key"` + Options raw.StringSlice `yaml:"options,omitempty" json:"options,omitempty" jsonschema:"description=List of options for the parameter.\nReference: https://go-vela.github.io/docs/reference/yaml/deployments/#the-parameters-key"` + Min int `yaml:"min,omitempty" json:"min,omitempty" jsonschema:"description=Minimum value for the parameter.\nReference: https://go-vela.github.io/docs/reference/yaml/deployments/#the-parameters-key"` + Max int `yaml:"max,omitempty" json:"max,omitempty" jsonschema:"description=Maximum value for the parameter.\nReference: https://go-vela.github.io/docs/reference/yaml/deployments/#the-parameters-key"` + } +) + +// ToPipeline converts the Deployment type +// to a pipeline Deployment type. +func (d *Deployment) ToPipeline() *pipeline.Deployment { + return &pipeline.Deployment{ + Targets: d.Targets, + Parameters: d.Parameters.ToPipeline(), + } +} + +// ToPipeline converts the Parameters type +// to a pipeline Parameters type. +func (p *ParameterMap) ToPipeline() pipeline.ParameterMap { + if len(*p) == 0 { + return nil + } + + // parameter map we want to return + parameterMap := make(pipeline.ParameterMap) + + // iterate through each element in the parameter map + for k, v := range *p { + // add the element to the pipeline parameter map + parameterMap[k] = &pipeline.Parameter{ + Description: v.Description, + Type: v.Type, + Required: v.Required, + Options: v.Options, + Min: v.Min, + Max: v.Max, + } + } + + return parameterMap +} diff --git a/compiler/types/yaml/deployment_test.go b/compiler/types/yaml/deployment_test.go new file mode 100644 index 000000000..da7fecd42 --- /dev/null +++ b/compiler/types/yaml/deployment_test.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 + +package yaml + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/go-vela/server/compiler/types/pipeline" +) + +func TestYaml_Deployment_ToPipeline(t *testing.T) { + // setup tests + tests := []struct { + name string + deployment *Deployment + want *pipeline.Deployment + }{ + { + name: "deployment with template", + deployment: &Deployment{ + Template: StepTemplate{Name: "foo"}, + }, + want: &pipeline.Deployment{}, + }, + { + name: "deployment with targets and parameters", + deployment: &Deployment{ + Targets: []string{"foo"}, + Parameters: ParameterMap{ + "foo": { + Description: "bar", + Type: "string", + Required: true, + Options: []string{"baz"}, + }, + "bar": { + Description: "baz", + Type: "string", + Required: false, + }, + }, + }, + want: &pipeline.Deployment{ + Targets: []string{"foo"}, + Parameters: pipeline.ParameterMap{ + "foo": { + Description: "bar", + Type: "string", + Required: true, + Options: []string{"baz"}, + }, + "bar": { + Description: "baz", + Type: "string", + Required: false, + }, + }, + }, + }, + { + name: "empty deployment config", + deployment: &Deployment{}, + want: &pipeline.Deployment{}, + }, + } + + // run tests + for _, test := range tests { + got := test.deployment.ToPipeline() + + if diff := cmp.Diff(test.want, got); diff != "" { + t.Errorf("ToPipeline for %s does not match: -want +got):\n%s", test.name, diff) + } + } +} diff --git a/compiler/types/yaml/testdata/build_with_deploy_config.yml b/compiler/types/yaml/testdata/build_with_deploy_config.yml new file mode 100644 index 000000000..61aea8b48 --- /dev/null +++ b/compiler/types/yaml/testdata/build_with_deploy_config.yml @@ -0,0 +1,36 @@ +version: "1" + +deployment: + targets: [ dev, stage, production ] + parameters: + alpha: + description: primary node name + required: true + type: string + options: + - north + - south + + beta: + description: secondary node name + required: false + type: string + options: + - east + - west + + cluster_count: + description: number of clusters to deploy + required: false + type: integer + + canary: + description: deploy with canary strategy + required: true + type: boolean + +steps: + - name: deploy plugin + image: awesome-plugin:latest + commands: + - ./deploy.sh diff --git a/compiler/types/yaml/testdata/deploy_parameter.yml b/compiler/types/yaml/testdata/deploy_parameter.yml new file mode 100644 index 000000000..89a9e0ae6 --- /dev/null +++ b/compiler/types/yaml/testdata/deploy_parameter.yml @@ -0,0 +1,11 @@ +--- +foo: + description: bar + required: true + type: string + options: + - baz +hello: + description: baz + required: false + type: string \ No newline at end of file diff --git a/router/deployment.go b/router/deployment.go index 0f7640561..0665bf051 100644 --- a/router/deployment.go +++ b/router/deployment.go @@ -16,7 +16,8 @@ import ( // // POST /api/v1/deployments/:org/:repo // GET /api/v1/deployments/:org/:repo -// GET /api/v1/deployments/:org/:repo/:deployment . +// GET /api/v1/deployments/:org/:repo/:deployment +// GET /api/v1/deployments/:org/:repo/config . func DeploymentHandlers(base *gin.RouterGroup) { // Deployments endpoints deployments := base.Group("/deployments/:org/:repo", org.Establish(), repo.Establish()) @@ -24,5 +25,6 @@ func DeploymentHandlers(base *gin.RouterGroup) { deployments.POST("", perm.MustWrite(), deployment.CreateDeployment) deployments.GET("", perm.MustRead(), deployment.ListDeployments) deployments.GET("/:deployment", perm.MustRead(), deployment.GetDeployment) + deployments.GET("/config", perm.MustRead(), deployment.GetDeploymentConfig) } // end of deployments endpoints } diff --git a/scm/github/webhook.go b/scm/github/webhook.go index 91a3854f2..61bdd2135 100644 --- a/scm/github/webhook.go +++ b/scm/github/webhook.go @@ -416,6 +416,8 @@ func (c *client) processDeploymentEvent(h *api.Hook, payload *github.DeploymentE if len(deployPayload) != 0 { // set the payload info on the build b.SetDeployPayload(deployPayload) + // set payload info on the deployment + d.SetPayload(deployPayload) } } diff --git a/scm/github/webhook_test.go b/scm/github/webhook_test.go index 0d342ccdb..c736a94d2 100644 --- a/scm/github/webhook_test.go +++ b/scm/github/webhook_test.go @@ -622,6 +622,7 @@ func TestGithub_ProcessWebhook_Deployment(t *testing.T) { wantDeployment.SetTask("deploy") wantDeployment.SetTarget("production") wantDeployment.SetDescription("") + wantDeployment.SetPayload(raw.StringSliceMap{"foo": "test1", "bar": "test2"}) wantDeployment.SetCreatedAt(time.Now().UTC().Unix()) wantDeployment.SetCreatedBy("Codertocat")