diff --git a/docs/concepts/deploy.yml.md b/docs/concepts/deploy.yml.md index cd60e143..3d8e9bcb 100644 --- a/docs/concepts/deploy.yml.md +++ b/docs/concepts/deploy.yml.md @@ -62,6 +62,24 @@ envs: production_environment: true ``` +### Deploy Freeze Window + +Gitploy support to add a window to prevent unintended deployment for the environment. You can freeze a window periodically by a cron expression. + +```yaml +envs: + - name: production + frozen_windows: + # Freeze every midnights + - start: "50 23 * * *" + duration: 20m + location: America/New_York + # Freeze every weekends + - start: "0 * * * SAT,SUN" + duration: 1h + location: Asia/Seoul +``` + ### Review Gitploy provides the review process. You can list up to users on the configuration file. You can check the [document](./review.md) for the detail. diff --git a/docs/references/deploy.yml.md b/docs/references/deploy.yml.md index a6b2e15c..9a32685d 100644 --- a/docs/references/deploy.yml.md +++ b/docs/references/deploy.yml.md @@ -11,22 +11,31 @@ Field |Type |Required |Description Field |Type |Required |Description --- |---- |--- |--- `name` |*string* |`true` |This field is the runtime environment such as `production`, `staging`, and `qa`. -`task` |*string* |`false` |This field is used by the deployment system to distinguish the kind of deployment. -`description` |*string* |`false` |This field is the short description of the deployment. -`auto_merge` |*boolean* |`false` |This field is used to ensure that the requested ref is not behind the repository's default branch. If you deploy with the commit or the tag you need to set `false`. For rollback, Gitploy set the field `false`. -`required_contexts` |*[]string* |`false` |This field allows you to specify a subset of contexts that must be success. -`payload` |*object* or *string* |`false` |This field is JSON payload with extra information about the deployment. +`task` |*string* |`false` |This field is used by the deployment system to distinguish the kind of deployment. (*Only for `GitHub`*) +`description` |*string* |`false` |This field is the short description of the deployment. (*Only for `GitHub`*) +`auto_merge` |*boolean* |`false` |This field is used to ensure that the requested ref is not behind the repository's default branch. If you deploy with the commit or the tag you need to set `false`. For rollback, Gitploy set the field `false`. (*Only for `GitHub`*) +`required_contexts` |*[]string* |`false` |This field allows you to specify a subset of contexts that must be success. (*Only for `GitHub`*) +`payload` |*object* or *string* |`false` |This field is JSON payload with extra information about the deployment. (*Only for `GitHub`*) `production_environment` |*boolean* |`false` |This field specifies whether this runtime environment is production or not. `deployable_ref` |*string* |`false` |This field specifies which the ref(branch, SHA, tag) is deployable or not. It supports the regular expression ([re2]((https://github.com/google/re2/wiki/Syntax))). `auto_deploy_on` |*string* |`false` |This field controls auto-deployment behaviour given a ref(branch, SHA, tag). If any new push events are detected on this event, the deployment will be triggered. It supports the regular expression ([re2](https://github.com/google/re2/wiki/Syntax)). E.g. `refs/heads/main` or `refs/tags/v.*` -`review` |*[Review](#review)* |`false` |This field configures review. +`review` |*[Review](#review)* |`false` |This field configures reviewers. +`frozen_windows` |*[][Frozen Window](#frozen-window)* |`false` |This field configures to add a frozen window to prevent unintended deployment for the environment. ## Review -Field |Type |Tag |Description ---- |--- |--- |--- -`enabled` |*boolean* |`true` |This field make to enable the review feature. The default value is `false`. -`reviewers` |*[]string* |`false` |This field list up reviewers. The default value is `[]`. You should specify maintainers of the project. +Field |Type |Required |Description +--- |--- |--- |--- +`enabled` |*boolean* |`false` |This field makes to enables the review feature. The default value is `false`. +`reviewers` |*[]string* |`false` |This field list up reviewers. The default value is `[]`. You should specify the maintainers of the project. + +## Frozen Window + +Field |Type |Required |Description +--- |--- |--- |--- +`start` |*string* |`true` |This field is a cron expression to indicate when the window starts. For example, `55 23 * * *` means it starts to freeze a window before 5 minutes of midnight. You can check the [documentation](https://github.com/gitploy-io/cronexpr) for details. +`duration` |*string* |`true` |This field configures how long the window is frozen from the starting. The duration string is a possibly signed sequence of decimal numbers and a unit suffix such as `5m`, or `1h30m`. Valid time units are `ns`, `us`, `ms`, `s`, `m`, `h`. +`location` |*string* |`false` |This field configures the location of the `start` time. The value is taken to be a location name corresponding to a file in the IANA Time Zone database, such as `America/New_York`. The default value is `UTC`. You can check the [document](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the Time Zone database name. ## Variables @@ -36,7 +45,7 @@ The following variables are available in `${ }` syntax when evaluating `deploy.y * `GITPLOY_ROLLBACK_TASK`: Returns `rollback` for rollback, but deploy, it returns the empty string. * `GITPLOY_IS_ROLLBACK`: Returns `true` for rollback, but deploy, it returns `false`. -An example usage of this: +Example usage of this: ```yaml envs: diff --git a/go.mod b/go.mod index 71f6fffb..e5e18692 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/gin-contrib/cors v1.3.1 github.com/gin-contrib/sse v0.1.0 github.com/gin-gonic/gin v1.7.7 + github.com/gitploy-io/cronexpr v0.2.2 github.com/go-sql-driver/mysql v1.5.1-0.20200311113236-681ffa848bae github.com/golang/mock v1.6.0 github.com/google/go-github/v32 v32.1.0 diff --git a/go.sum b/go.sum index 81ddeeef..ae9a9ffe 100644 --- a/go.sum +++ b/go.sum @@ -104,6 +104,12 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs= github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U= +github.com/gitploy-io/cronexpr v0.2.0 h1:BmYX3+QNB11Bevp2z98taCOiNDhMo/E1pBeO6OzojAs= +github.com/gitploy-io/cronexpr v0.2.0/go.mod h1:Uep5sbzUSocMZvJ1s0lNI9zi37s5iUI1llkw3vRGK9M= +github.com/gitploy-io/cronexpr v0.2.1 h1:usx6GTAQh2q3E4S8jx5RWGGkr4LSxLH0mBcebGZGv+c= +github.com/gitploy-io/cronexpr v0.2.1/go.mod h1:Uep5sbzUSocMZvJ1s0lNI9zi37s5iUI1llkw3vRGK9M= +github.com/gitploy-io/cronexpr v0.2.2 h1:Au+wK6FqmOLAF7AkW6q4gnrNXTe3rEW97XFZ4chy0xs= +github.com/gitploy-io/cronexpr v0.2.2/go.mod h1:Uep5sbzUSocMZvJ1s0lNI9zi37s5iUI1llkw3vRGK9M= github.com/go-bindata/go-bindata v1.0.1-0.20190711162640-ee3c2418e368/go.mod h1:7xCgX1lzlrXPHkfvn3EhumqHkmSlzt8at9q7v0ax19c= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= diff --git a/internal/interactor/deployment.go b/internal/interactor/deployment.go index ef0827b8..c6a4ba7b 100644 --- a/internal/interactor/deployment.go +++ b/internal/interactor/deployment.go @@ -14,6 +14,9 @@ import ( "go.uber.org/zap" ) +// IsApproved verifies that the request is approved or not. +// It is approved if there is an approval of reviews at least, but +// it is rejected if there is a reject of reviews. func (i *Interactor) IsApproved(ctx context.Context, d *ent.Deployment) bool { rvs, _ := i.Store.ListReviews(ctx, d) @@ -32,8 +35,12 @@ func (i *Interactor) IsApproved(ctx context.Context, d *ent.Deployment) bool { return false } +// Deploy posts a new deployment to SCM with the payload. +// But if it requires a review, it saves the payload on the DB +// and waits until reviewed. +// It returns an error for a undeployable payload. func (i *Interactor) Deploy(ctx context.Context, u *ent.User, r *ent.Repo, d *ent.Deployment, env *extent.Env) (*ent.Deployment, error) { - if ok, err := i.isDeployable(ctx, u, r, d, env); !ok { + if err := i.isDeployable(ctx, u, r, d, env); err != nil { return nil, err } @@ -114,7 +121,9 @@ func (i *Interactor) Deploy(ctx context.Context, u *ent.User, r *ent.Repo, d *en return d, nil } -// DeployToRemote create a new remote deployment after the deployment was approved. +// DeployToRemote posts a new deployment to SCM with the saved payload +// after review has finished. +// It returns an error for a undeployable payload. func (i *Interactor) DeployToRemote(ctx context.Context, u *ent.User, r *ent.Repo, d *ent.Deployment, env *extent.Env) (*ent.Deployment, error) { if d.Status != deployment.StatusWaiting { return nil, e.NewErrorWithMessage( @@ -124,7 +133,7 @@ func (i *Interactor) DeployToRemote(ctx context.Context, u *ent.User, r *ent.Rep ) } - if ok, err := i.isDeployable(ctx, u, r, d, env); !ok { + if err := i.isDeployable(ctx, u, r, d, env); err != nil { return nil, err } @@ -171,21 +180,30 @@ func (i *Interactor) createRemoteDeployment(ctx context.Context, u *ent.User, r return i.SCM.CreateRemoteDeployment(ctx, u, r, d, env) } -func (i *Interactor) isDeployable(ctx context.Context, u *ent.User, r *ent.Repo, d *ent.Deployment, env *extent.Env) (bool, error) { - if ok, err := env.IsDeployableRef(d.Ref); err != nil { - return false, err - } else if !ok { - return false, e.NewErrorWithMessage(e.ErrorCodeEntityUnprocessable, "The ref is not matched with 'deployable_ref'.", nil) +func (i *Interactor) isDeployable(ctx context.Context, u *ent.User, r *ent.Repo, d *ent.Deployment, env *extent.Env) error { + // Skip verifications for roll back. + if !d.IsRollback { + if ok, err := env.IsDeployableRef(d.Ref); !ok { + return e.NewErrorWithMessage(e.ErrorCodeEntityUnprocessable, "The ref is not matched with 'deployable_ref'.", nil) + } else if err != nil { + return err + } } // Check that the environment is locked. if locked, err := i.Store.HasLockOfRepoForEnv(ctx, r, d.Env); locked { - return false, e.NewError(e.ErrorCodeDeploymentLocked, err) + return e.NewError(e.ErrorCodeDeploymentLocked, err) + } else if err != nil { + return err + } + + if freezed, err := env.IsFreezed(time.Now().UTC()); freezed { + return e.NewError(e.ErrorCodeDeploymentFrozen, err) } else if err != nil { - return false, e.NewError(e.ErrorCodeInternalError, err) + return err } - return true, nil + return nil } func (i *Interactor) runClosingInactiveDeployment(stop <-chan struct{}) { diff --git a/model/extent/config.go b/model/extent/config.go index efdd5b4c..6fab0ecf 100644 --- a/model/extent/config.go +++ b/model/extent/config.go @@ -3,8 +3,11 @@ package extent import ( "regexp" "strconv" + "strings" + "time" "github.com/drone/envsubst" + "github.com/gitploy-io/cronexpr" "gopkg.in/yaml.v3" eutil "github.com/gitploy-io/gitploy/pkg/e" @@ -38,6 +41,9 @@ type ( // Review is the configuration of Review, // It is disabled when it is empty. Review *Review `json:"review,omitempty" yaml:"review"` + + // FrozenWindows is the list of windows to freeze deployments. + FrozenWindows []FrozenWindow `json:"frozen_windows" yaml:"frozen_windows"` } Review struct { @@ -45,6 +51,12 @@ type ( Reviewers []string `json:"reviewers" yaml:"reviewers"` } + FrozenWindow struct { + Start string `json:"start" yaml:"start"` + Duration string `json:"duration" yaml:"duration"` + Location string `json:"location" yaml:"location"` + } + EvalValues struct { IsRollback bool } @@ -131,12 +143,12 @@ func (c *Config) GetEnv(name string) *Env { return nil } -// IsProductionEnvironment check whether the environment is production or not. +// IsProductionEnvironment verifies whether the environment is production or not. func (e *Env) IsProductionEnvironment() bool { return e.ProductionEnvironment != nil && *e.ProductionEnvironment } -// IsDeployableRef validate the ref is deployable. +// IsDeployableRef verifies the ref is deployable. func (e *Env) IsDeployableRef(ref string) (bool, error) { if e.DeployableRef == nil { return true, nil @@ -150,7 +162,7 @@ func (e *Env) IsDeployableRef(ref string) (bool, error) { return matched, nil } -// IsAutoDeployOn validate the ref is matched with 'auto_deploy_on'. +// IsAutoDeployOn verifies the ref is matched with 'auto_deploy_on'. func (e *Env) IsAutoDeployOn(ref string) (bool, error) { if e.AutoDeployOn == nil { return false, nil @@ -168,3 +180,40 @@ func (e *Env) IsAutoDeployOn(ref string) (bool, error) { func (e *Env) HasReview() bool { return e.Review != nil && e.Review.Enabled } + +// IsFreezed verifies whether the current time is in a freeze window. +// It returns an error when parsing an expression is failed. +func (e *Env) IsFreezed(t time.Time) (bool, error) { + if len(e.FrozenWindows) == 0 { + return false, nil + } + + for _, w := range e.FrozenWindows { + s, err := cronexpr.ParseInLocation(strings.TrimSpace(w.Start), w.Location) + if err != nil { + return false, eutil.NewErrorWithMessage( + eutil.ErrorCodeConfigInvalid, + "The crontab expression of the freeze window is invalid.", + err, + ) + } + + d, err := time.ParseDuration(w.Duration) + if err != nil { + return false, eutil.NewErrorWithMessage( + eutil.ErrorCodeConfigInvalid, + "The duration of the freeze window is invalid.", + err, + ) + } + + // Add one minute to include the starting time. + start := s.Prev(t.Add(time.Minute)) + end := start.Add(d) + if t.After(start) && t.Before(end) { + return true, nil + } + } + + return false, nil +} diff --git a/model/extent/config_test.go b/model/extent/config_test.go index 81576b32..34c076d4 100644 --- a/model/extent/config_test.go +++ b/model/extent/config_test.go @@ -3,13 +3,14 @@ package extent import ( "reflect" "testing" + "time" "github.com/AlekSi/pointer" "github.com/davecgh/go-spew/spew" ) func TestUnmarshalYAML(t *testing.T) { - t.Run("unmarhsal the required_context field", func(tt *testing.T) { + t.Run("Unmarhsal the required_context field", func(tt *testing.T) { s := ` envs: - name: dev @@ -37,7 +38,7 @@ envs: } }) - t.Run("unmarshal auto_merge: false ", func(tt *testing.T) { + t.Run("Unmarshal 'auto_merge: false'", func(tt *testing.T) { s := ` envs: - name: dev @@ -64,7 +65,7 @@ envs: } }) - t.Run("unmarshal auto_merge: true", func(tt *testing.T) { + t.Run("Unmarshal 'auto_merge: true'", func(tt *testing.T) { s := ` envs: - name: dev @@ -92,7 +93,7 @@ envs: } func TestConfig_Eval(t *testing.T) { - t.Run("Evaluate the configuration.", func(t *testing.T) { + t.Run("Umarshal the task with the variable template.", func(t *testing.T) { s := ` envs: - name: dev @@ -122,7 +123,7 @@ envs: } }) - t.Run("Evaluate the configuration with the regexp.", func(t *testing.T) { + t.Run("Unmarshal the deployable_ref field with a regexp.", func(t *testing.T) { s := ` envs: - name: dev @@ -153,6 +154,38 @@ envs: t.Errorf("Config = %v expected %v", spew.Sdump(c), spew.Sdump(e)) } }) + + t.Run("Unmarshal the frozen_windows field", func(t *testing.T) { + s := ` +envs: + - name: dev + frozen_windows: + - start: "55 23 * * *" + duration: "10m"` + + c := &Config{} + if err := UnmarshalYAML([]byte(s), c); err != nil { + t.Fatalf("Failed to parse the configuration file: %v", err) + } + + e := &Config{ + Envs: []*Env{ + { + Name: "dev", + FrozenWindows: []FrozenWindow{ + { + Start: "55 23 * * *", + Duration: "10m", + }, + }, + }, + }, + source: []byte(s), + } + if !reflect.DeepEqual(c, e) { + t.Errorf("Config = %v expected %v", spew.Sdump(c), spew.Sdump(e)) + } + }) } func TestEnv_IsProductionEnvironment(t *testing.T) { @@ -224,3 +257,77 @@ func TestEnv_IsDeployableRef(t *testing.T) { } }) } + +func TestEnv_IsFreezed(t *testing.T) { + t.Run("Return true when the time is in the window", func(t *testing.T) { + runs := []struct { + t time.Time + e *Env + want bool + }{ + { + t: time.Date(2012, 12, 1, 23, 55, 10, 0, time.UTC), + e: &Env{ + FrozenWindows: []FrozenWindow{ + { + Start: "55 23 * Dec *", + Duration: "10m", + }, + }, + }, + want: true, + }, + { + t: time.Date(2012, 1, 1, 0, 3, 0, 0, time.UTC), + e: &Env{ + FrozenWindows: []FrozenWindow{ + { + Start: "55 23 * Dec *", + Duration: "10m", + }, + }, + }, + want: true, + }, + } + e := &Env{ + FrozenWindows: []FrozenWindow{ + { + Start: "55 23 * Dec *", + Duration: "10m", + }, + }, + } + + for _, r := range runs { + freezed, err := e.IsFreezed(r.t) + if err != nil { + t.Fatalf("IsFreezed returns an error: %s", err) + } + + if freezed != r.want { + t.Fatalf("IsFreezed = %v, wanted %v", freezed, r.want) + } + } + }) + + t.Run("Return false when the time is out of the window", func(t *testing.T) { + e := &Env{ + FrozenWindows: []FrozenWindow{ + { + Start: "55 23 * Dec *", + Duration: "10m", + }, + }, + } + + freezed, err := e.IsFreezed(time.Date(2012, 1, 1, 0, 10, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("IsFreezed returns an error: %s", err) + } + + if freezed != false { + t.Fatalf("IsFreezed = %v, wanted %v", freezed, false) + } + }) +} diff --git a/pkg/e/code.go b/pkg/e/code.go index 50b54096..0f0a739f 100644 --- a/pkg/e/code.go +++ b/pkg/e/code.go @@ -17,6 +17,8 @@ const ( ErrorCodeDeploymentInvalid ErrorCode = "deployment_invalid" // ErrorCodeDeploymentLocked is when the environment is locked. ErrorCodeDeploymentLocked ErrorCode = "deployment_locked" + // ErrorCodeDeploymentFrozen is when the time in in the freeze window. + ErrorCodeDeploymentFrozen ErrorCode = "deployment_frozen" // ErrorCodeDeploymentUnapproved is when the deployment is not approved. ErrorCodeDeploymentNotApproved ErrorCode = "deployment_not_approved" // ErrorCodeDeploymentStatusNotWaiting is the status must be 'waiting' to create a remote deployment. diff --git a/pkg/e/trans.go b/pkg/e/trans.go index 088747cd..7e570403 100644 --- a/pkg/e/trans.go +++ b/pkg/e/trans.go @@ -8,6 +8,7 @@ var messages = map[ErrorCode]string{ ErrorCodeDeploymentConflict: "The conflict occurs, please retry.", ErrorCodeDeploymentInvalid: "The validation has failed.", ErrorCodeDeploymentLocked: "The environment is locked.", + ErrorCodeDeploymentFrozen: "It is in the deploy freeze window.", ErrorCodeDeploymentNotApproved: "The deployment is not approved", ErrorCodeDeploymentStatusInvalid: "The deployment status is invalid", ErrorCodeEntityNotFound: "It is not found.", @@ -35,6 +36,7 @@ var httpCodes = map[ErrorCode]int{ ErrorCodeDeploymentConflict: http.StatusUnprocessableEntity, ErrorCodeDeploymentInvalid: http.StatusUnprocessableEntity, ErrorCodeDeploymentLocked: http.StatusUnprocessableEntity, + ErrorCodeDeploymentFrozen: http.StatusUnprocessableEntity, ErrorCodeDeploymentNotApproved: http.StatusUnprocessableEntity, ErrorCodeDeploymentStatusInvalid: http.StatusUnprocessableEntity, ErrorCodeEntityNotFound: http.StatusNotFound,