diff --git a/docs/concepts/deployment.md b/docs/concepts/deployment.md index 4a51770e..f9b14020 100644 --- a/docs/concepts/deployment.md +++ b/docs/concepts/deployment.md @@ -10,6 +10,8 @@ With approval, Gitploy waits until it matches the required approving approvals. ## Rollback -Rollback is the best way to recover while you fix the problems. Gitploy supports the rollback. You can choose one of the successful deployments to rollback. +Rollback is the best way to recover while you fix the problems. Gitploy supports the rollback, and you can choose one of the successful deployments to rollback. + +For best practice, you should lock the repository to block deploying by others. And the admin user has to take care of the repository until finishing to fix the problems. *Note that if the ref of the selected deployment is a branch, Gitploy automatically references the commit SHA to prevent deploying the head of the branch.* diff --git a/docs/concepts/permission.md b/docs/concepts/permission.md index 9d8571a7..3123b837 100644 --- a/docs/concepts/permission.md +++ b/docs/concepts/permission.md @@ -13,6 +13,6 @@ Here are capabilities for each permission: * **Write** - Users can deploy and rollback for the `REF`. -* **Admin** - Users can modify repository settings, including activate and deactivate. +* **Admin** - Users can configures the repository, such as activating and locking. Of course, write and admin permission cover the ability of read permission. \ No newline at end of file diff --git a/ent/migrate/schema.go b/ent/migrate/schema.go index 4745cf74..076b3923 100644 --- a/ent/migrate/schema.go +++ b/ent/migrate/schema.go @@ -285,6 +285,7 @@ var ( {Name: "config_path", Type: field.TypeString, Default: "deploy.yml"}, {Name: "active", Type: field.TypeBool, Default: false}, {Name: "webhook_id", Type: field.TypeInt64, Nullable: true}, + {Name: "locked", Type: field.TypeBool, Default: false}, {Name: "created_at", Type: field.TypeTime}, {Name: "updated_at", Type: field.TypeTime}, {Name: "latest_deployed_at", Type: field.TypeTime, Nullable: true}, diff --git a/ent/mutation.go b/ent/mutation.go index 45ca1d0e..d94b482a 100644 --- a/ent/mutation.go +++ b/ent/mutation.go @@ -5881,6 +5881,7 @@ type RepoMutation struct { active *bool webhook_id *int64 addwebhook_id *int64 + locked *bool created_at *time.Time updated_at *time.Time latest_deployed_at *time.Time @@ -6234,6 +6235,42 @@ func (m *RepoMutation) ResetWebhookID() { delete(m.clearedFields, repo.FieldWebhookID) } +// SetLocked sets the "locked" field. +func (m *RepoMutation) SetLocked(b bool) { + m.locked = &b +} + +// Locked returns the value of the "locked" field in the mutation. +func (m *RepoMutation) Locked() (r bool, exists bool) { + v := m.locked + if v == nil { + return + } + return *v, true +} + +// OldLocked returns the old "locked" field's value of the Repo entity. +// If the Repo object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *RepoMutation) OldLocked(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, fmt.Errorf("OldLocked is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, fmt.Errorf("OldLocked requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldLocked: %w", err) + } + return oldValue.Locked, nil +} + +// ResetLocked resets all changes to the "locked" field. +func (m *RepoMutation) ResetLocked() { + m.locked = nil +} + // SetCreatedAt sets the "created_at" field. func (m *RepoMutation) SetCreatedAt(t time.Time) { m.created_at = &t @@ -6536,7 +6573,7 @@ func (m *RepoMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *RepoMutation) Fields() []string { - fields := make([]string, 0, 9) + fields := make([]string, 0, 10) if m.namespace != nil { fields = append(fields, repo.FieldNamespace) } @@ -6555,6 +6592,9 @@ func (m *RepoMutation) Fields() []string { if m.webhook_id != nil { fields = append(fields, repo.FieldWebhookID) } + if m.locked != nil { + fields = append(fields, repo.FieldLocked) + } if m.created_at != nil { fields = append(fields, repo.FieldCreatedAt) } @@ -6584,6 +6624,8 @@ func (m *RepoMutation) Field(name string) (ent.Value, bool) { return m.Active() case repo.FieldWebhookID: return m.WebhookID() + case repo.FieldLocked: + return m.Locked() case repo.FieldCreatedAt: return m.CreatedAt() case repo.FieldUpdatedAt: @@ -6611,6 +6653,8 @@ func (m *RepoMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldActive(ctx) case repo.FieldWebhookID: return m.OldWebhookID(ctx) + case repo.FieldLocked: + return m.OldLocked(ctx) case repo.FieldCreatedAt: return m.OldCreatedAt(ctx) case repo.FieldUpdatedAt: @@ -6668,6 +6712,13 @@ func (m *RepoMutation) SetField(name string, value ent.Value) error { } m.SetWebhookID(v) return nil + case repo.FieldLocked: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetLocked(v) + return nil case repo.FieldCreatedAt: v, ok := value.(time.Time) if !ok { @@ -6786,6 +6837,9 @@ func (m *RepoMutation) ResetField(name string) error { case repo.FieldWebhookID: m.ResetWebhookID() return nil + case repo.FieldLocked: + m.ResetLocked() + return nil case repo.FieldCreatedAt: m.ResetCreatedAt() return nil diff --git a/ent/repo.go b/ent/repo.go index fe1668f6..c9954621 100644 --- a/ent/repo.go +++ b/ent/repo.go @@ -28,6 +28,8 @@ type Repo struct { Active bool `json:"active"` // WebhookID holds the value of the "webhook_id" field. WebhookID int64 `json:"webhook_id,omitemtpy"` + // Locked holds the value of the "locked" field. + Locked bool `json:"locked"` // CreatedAt holds the value of the "created_at" field. CreatedAt time.Time `json:"created_at"` // UpdatedAt holds the value of the "updated_at" field. @@ -84,7 +86,7 @@ func (*Repo) scanValues(columns []string) ([]interface{}, error) { values := make([]interface{}, len(columns)) for i := range columns { switch columns[i] { - case repo.FieldActive: + case repo.FieldActive, repo.FieldLocked: values[i] = new(sql.NullBool) case repo.FieldWebhookID: values[i] = new(sql.NullInt64) @@ -149,6 +151,12 @@ func (r *Repo) assignValues(columns []string, values []interface{}) error { } else if value.Valid { r.WebhookID = value.Int64 } + case repo.FieldLocked: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field locked", values[i]) + } else if value.Valid { + r.Locked = value.Bool + } case repo.FieldCreatedAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field created_at", values[i]) @@ -222,6 +230,8 @@ func (r *Repo) String() string { builder.WriteString(fmt.Sprintf("%v", r.Active)) builder.WriteString(", webhook_id=") builder.WriteString(fmt.Sprintf("%v", r.WebhookID)) + builder.WriteString(", locked=") + builder.WriteString(fmt.Sprintf("%v", r.Locked)) builder.WriteString(", created_at=") builder.WriteString(r.CreatedAt.Format(time.ANSIC)) builder.WriteString(", updated_at=") diff --git a/ent/repo/repo.go b/ent/repo/repo.go index 8eba2080..bf8e850f 100644 --- a/ent/repo/repo.go +++ b/ent/repo/repo.go @@ -23,6 +23,8 @@ const ( FieldActive = "active" // FieldWebhookID holds the string denoting the webhook_id field in the database. FieldWebhookID = "webhook_id" + // FieldLocked holds the string denoting the locked field in the database. + FieldLocked = "locked" // FieldCreatedAt holds the string denoting the created_at field in the database. FieldCreatedAt = "created_at" // FieldUpdatedAt holds the string denoting the updated_at field in the database. @@ -69,6 +71,7 @@ var Columns = []string{ FieldConfigPath, FieldActive, FieldWebhookID, + FieldLocked, FieldCreatedAt, FieldUpdatedAt, FieldLatestDeployedAt, @@ -89,6 +92,8 @@ var ( DefaultConfigPath string // DefaultActive holds the default value on creation for the "active" field. DefaultActive bool + // DefaultLocked holds the default value on creation for the "locked" field. + DefaultLocked bool // DefaultCreatedAt holds the default value on creation for the "created_at" field. DefaultCreatedAt func() time.Time // DefaultUpdatedAt holds the default value on creation for the "updated_at" field. diff --git a/ent/repo/where.go b/ent/repo/where.go index 53cb9535..91cc7415 100644 --- a/ent/repo/where.go +++ b/ent/repo/where.go @@ -135,6 +135,13 @@ func WebhookID(v int64) predicate.Repo { }) } +// Locked applies equality check predicate on the "locked" field. It's identical to LockedEQ. +func Locked(v bool) predicate.Repo { + return predicate.Repo(func(s *sql.Selector) { + s.Where(sql.EQ(s.C(FieldLocked), v)) + }) +} + // CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. func CreatedAt(v time.Time) predicate.Repo { return predicate.Repo(func(s *sql.Selector) { @@ -704,6 +711,20 @@ func WebhookIDNotNil() predicate.Repo { }) } +// LockedEQ applies the EQ predicate on the "locked" field. +func LockedEQ(v bool) predicate.Repo { + return predicate.Repo(func(s *sql.Selector) { + s.Where(sql.EQ(s.C(FieldLocked), v)) + }) +} + +// LockedNEQ applies the NEQ predicate on the "locked" field. +func LockedNEQ(v bool) predicate.Repo { + return predicate.Repo(func(s *sql.Selector) { + s.Where(sql.NEQ(s.C(FieldLocked), v)) + }) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.Repo { return predicate.Repo(func(s *sql.Selector) { diff --git a/ent/repo_create.go b/ent/repo_create.go index 423d29f2..1e70936f 100644 --- a/ent/repo_create.go +++ b/ent/repo_create.go @@ -83,6 +83,20 @@ func (rc *RepoCreate) SetNillableWebhookID(i *int64) *RepoCreate { return rc } +// SetLocked sets the "locked" field. +func (rc *RepoCreate) SetLocked(b bool) *RepoCreate { + rc.mutation.SetLocked(b) + return rc +} + +// SetNillableLocked sets the "locked" field if the given value is not nil. +func (rc *RepoCreate) SetNillableLocked(b *bool) *RepoCreate { + if b != nil { + rc.SetLocked(*b) + } + return rc +} + // SetCreatedAt sets the "created_at" field. func (rc *RepoCreate) SetCreatedAt(t time.Time) *RepoCreate { rc.mutation.SetCreatedAt(t) @@ -255,6 +269,10 @@ func (rc *RepoCreate) defaults() { v := repo.DefaultActive rc.mutation.SetActive(v) } + if _, ok := rc.mutation.Locked(); !ok { + v := repo.DefaultLocked + rc.mutation.SetLocked(v) + } if _, ok := rc.mutation.CreatedAt(); !ok { v := repo.DefaultCreatedAt() rc.mutation.SetCreatedAt(v) @@ -282,6 +300,9 @@ func (rc *RepoCreate) check() error { if _, ok := rc.mutation.Active(); !ok { return &ValidationError{Name: "active", err: errors.New(`ent: missing required field "active"`)} } + if _, ok := rc.mutation.Locked(); !ok { + return &ValidationError{Name: "locked", err: errors.New(`ent: missing required field "locked"`)} + } if _, ok := rc.mutation.CreatedAt(); !ok { return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "created_at"`)} } @@ -365,6 +386,14 @@ func (rc *RepoCreate) createSpec() (*Repo, *sqlgraph.CreateSpec) { }) _node.WebhookID = value } + if value, ok := rc.mutation.Locked(); ok { + _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ + Type: field.TypeBool, + Value: value, + Column: repo.FieldLocked, + }) + _node.Locked = value + } if value, ok := rc.mutation.CreatedAt(); ok { _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ Type: field.TypeTime, diff --git a/ent/repo_update.go b/ent/repo_update.go index c9cc988f..6e3d0023 100644 --- a/ent/repo_update.go +++ b/ent/repo_update.go @@ -103,6 +103,20 @@ func (ru *RepoUpdate) ClearWebhookID() *RepoUpdate { return ru } +// SetLocked sets the "locked" field. +func (ru *RepoUpdate) SetLocked(b bool) *RepoUpdate { + ru.mutation.SetLocked(b) + return ru +} + +// SetNillableLocked sets the "locked" field if the given value is not nil. +func (ru *RepoUpdate) SetNillableLocked(b *bool) *RepoUpdate { + if b != nil { + ru.SetLocked(*b) + } + return ru +} + // SetCreatedAt sets the "created_at" field. func (ru *RepoUpdate) SetCreatedAt(t time.Time) *RepoUpdate { ru.mutation.SetCreatedAt(t) @@ -392,6 +406,13 @@ func (ru *RepoUpdate) sqlSave(ctx context.Context) (n int, err error) { Column: repo.FieldWebhookID, }) } + if value, ok := ru.mutation.Locked(); ok { + _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ + Type: field.TypeBool, + Value: value, + Column: repo.FieldLocked, + }) + } if value, ok := ru.mutation.CreatedAt(); ok { _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ Type: field.TypeTime, @@ -673,6 +694,20 @@ func (ruo *RepoUpdateOne) ClearWebhookID() *RepoUpdateOne { return ruo } +// SetLocked sets the "locked" field. +func (ruo *RepoUpdateOne) SetLocked(b bool) *RepoUpdateOne { + ruo.mutation.SetLocked(b) + return ruo +} + +// SetNillableLocked sets the "locked" field if the given value is not nil. +func (ruo *RepoUpdateOne) SetNillableLocked(b *bool) *RepoUpdateOne { + if b != nil { + ruo.SetLocked(*b) + } + return ruo +} + // SetCreatedAt sets the "created_at" field. func (ruo *RepoUpdateOne) SetCreatedAt(t time.Time) *RepoUpdateOne { ruo.mutation.SetCreatedAt(t) @@ -986,6 +1021,13 @@ func (ruo *RepoUpdateOne) sqlSave(ctx context.Context) (_node *Repo, err error) Column: repo.FieldWebhookID, }) } + if value, ok := ruo.mutation.Locked(); ok { + _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ + Type: field.TypeBool, + Value: value, + Column: repo.FieldLocked, + }) + } if value, ok := ruo.mutation.CreatedAt(); ok { _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ Type: field.TypeTime, diff --git a/ent/runtime.go b/ent/runtime.go index 96a3deb8..40fa5e8b 100644 --- a/ent/runtime.go +++ b/ent/runtime.go @@ -129,12 +129,16 @@ func init() { repoDescActive := repoFields[5].Descriptor() // repo.DefaultActive holds the default value on creation for the active field. repo.DefaultActive = repoDescActive.Default.(bool) + // repoDescLocked is the schema descriptor for locked field. + repoDescLocked := repoFields[7].Descriptor() + // repo.DefaultLocked holds the default value on creation for the locked field. + repo.DefaultLocked = repoDescLocked.Default.(bool) // repoDescCreatedAt is the schema descriptor for created_at field. - repoDescCreatedAt := repoFields[7].Descriptor() + repoDescCreatedAt := repoFields[8].Descriptor() // repo.DefaultCreatedAt holds the default value on creation for the created_at field. repo.DefaultCreatedAt = repoDescCreatedAt.Default.(func() time.Time) // repoDescUpdatedAt is the schema descriptor for updated_at field. - repoDescUpdatedAt := repoFields[8].Descriptor() + repoDescUpdatedAt := repoFields[9].Descriptor() // repo.DefaultUpdatedAt holds the default value on creation for the updated_at field. repo.DefaultUpdatedAt = repoDescUpdatedAt.Default.(func() time.Time) // repo.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. diff --git a/ent/schema/repo.go b/ent/schema/repo.go index 79b3708d..c747ae88 100644 --- a/ent/schema/repo.go +++ b/ent/schema/repo.go @@ -23,10 +23,13 @@ func (Repo) Fields() []ent.Field { field.String("description"), field.String("config_path"). Default("deploy.yml"), + // Activated repo has the webhook to update the deployment status. field.Bool("active"). Default(false), field.Int64("webhook_id"). Optional(), + field.Bool("locked"). + Default(false), field.Time("created_at"). Default(time.Now), field.Time("updated_at"). diff --git a/internal/pkg/store/repo.go b/internal/pkg/store/repo.go index ebc8391f..ecc615fe 100644 --- a/internal/pkg/store/repo.go +++ b/internal/pkg/store/repo.go @@ -104,6 +104,7 @@ func (s *Store) UpdateRepo(ctx context.Context, r *ent.Repo) (*ent.Repo, error) return s.c.Repo. UpdateOne(r). SetConfigPath(r.ConfigPath). + SetLocked(r.Locked). Save(ctx) } diff --git a/internal/server/api/v1/repos/deployment.go b/internal/server/api/v1/repos/deployment.go index a5d94c82..4e762c4d 100644 --- a/internal/server/api/v1/repos/deployment.go +++ b/internal/server/api/v1/repos/deployment.go @@ -87,6 +87,12 @@ func (r *Repo) CreateDeployment(c *gin.Context) { vr, _ := c.Get(KeyRepo) re := vr.(*ent.Repo) + if re.Locked { + r.log.Warn("The repository is locked. It blocks to deploy.") + gb.ErrorResponse(c, http.StatusUnprocessableEntity, "The repository is locked. It blocks to deploy.") + return + } + cf, err := r.i.GetConfig(ctx, u, re) if vo.IsConfigNotFoundError(err) { r.log.Warn("failed to get the config.", zap.Error(err)) @@ -153,6 +159,8 @@ func (r *Repo) CreateDeployment(c *gin.Context) { gb.Response(c, http.StatusCreated, d) } +// UpdateDeployment creates a new remote deployment and +// patch the deployment status 'created'. func (r *Repo) UpdateDeployment(c *gin.Context) { ctx := c.Request.Context() @@ -178,6 +186,12 @@ func (r *Repo) UpdateDeployment(c *gin.Context) { gb.ErrorResponse(c, http.StatusNotFound, "The deployment is not found.") } + if re.Locked { + r.log.Warn("The repository is locked. It blocks to deploy.") + gb.ErrorResponse(c, http.StatusUnprocessableEntity, "The repository is locked. It blocks to deploy.") + return + } + cf, err := r.i.GetConfig(ctx, u, re) if vo.IsConfigNotFoundError(err) { r.log.Warn("failed to get the config.", zap.Error(err)) @@ -255,6 +269,12 @@ func (r *Repo) RollbackDeployment(c *gin.Context) { return } + if re.Locked { + r.log.Warn("The repository is locked. It blocks to deploy.") + gb.ErrorResponse(c, http.StatusUnprocessableEntity, "The repository is locked. It blocks to deploy.") + return + } + cf, err := r.i.GetConfig(ctx, u, re) if vo.IsConfigNotFoundError(err) { r.log.Warn("failed to get the config.", zap.Error(err)) diff --git a/internal/server/api/v1/repos/deployment_test.go b/internal/server/api/v1/repos/deployment_test.go index b1b83ecb..d0542d83 100644 --- a/internal/server/api/v1/repos/deployment_test.go +++ b/internal/server/api/v1/repos/deployment_test.go @@ -1,6 +1,7 @@ package repos import ( + "bytes" "encoding/json" "fmt" "net/http" @@ -103,3 +104,112 @@ func TestRepo_ListDeploymentChanges(t *testing.T) { } }) } + +func TestRepo_CreateDeployment(t *testing.T) { + t.Run("422 error when request to the locked repo.", func(t *testing.T) { + input := struct { + payload *deploymentPostPayload + }{ + payload: &deploymentPostPayload{ + Type: "branch", + Ref: "main", + Env: "prod", + }, + } + + ctrl := gomock.NewController(t) + m := mock.NewMockInteractor(ctrl) + + r := NewRepo(RepoConfig{}, m) + + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.POST("/repos/:id/deployments", func(c *gin.Context) { + c.Set(global.KeyUser, &ent.User{}) + // The repos is locked. + c.Set(KeyRepo, &ent.Repo{Locked: true}) + }, r.CreateDeployment) + + body, _ := json.Marshal(input.payload) + req, _ := http.NewRequest("POST", "/repos/1/deployments", bytes.NewBuffer(body)) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("Code = %v, wanted %v. Body=%v", w.Code, http.StatusCreated, w.Body) + } + + }) + + t.Run("a new deployment entity.", func(t *testing.T) { + input := struct { + payload *deploymentPostPayload + }{ + payload: &deploymentPostPayload{ + Type: "branch", + Ref: "main", + Env: "prod", + }, + } + + ctrl := gomock.NewController(t) + m := mock.NewMockInteractor(ctrl) + + t.Log("Read the config file.") + m. + EXPECT(). + GetConfig(gomock.Any(), gomock.AssignableToTypeOf(&ent.User{}), gomock.AssignableToTypeOf(&ent.Repo{})). + Return(&vo.Config{ + Envs: []*vo.Env{ + {Name: "prod"}, + }, + }, nil) + + t.Log("Return the next deployment number.") + m. + EXPECT(). + GetNextDeploymentNumberOfRepo(gomock.Any(), gomock.AssignableToTypeOf(&ent.Repo{})). + Return(4, nil) + + t.Log("Deploy with the payload successfully.") + m. + EXPECT(). + Deploy(gomock.Any(), gomock.AssignableToTypeOf(&ent.User{}), gomock.AssignableToTypeOf(&ent.Repo{}), gomock.Eq(&ent.Deployment{ + Number: 4, + Type: deployment.Type(input.payload.Type), + Env: input.payload.Env, + Ref: input.payload.Ref, + }), gomock.AssignableToTypeOf(&vo.Env{})). + Return(&ent.Deployment{}, nil) + + t.Log("Dispatch the event.") + m. + EXPECT(). + CreateEvent(gomock.Any(), gomock.AssignableToTypeOf(&ent.Event{})). + Return(&ent.Event{}, nil) + + t.Log("Read the deployment with edges.") + m. + EXPECT(). + FindDeploymentByID(gomock.Any(), gomock.Any()). + Return(&ent.Deployment{}, nil) + + r := NewRepo(RepoConfig{}, m) + + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.POST("/repos/:id/deployments", func(c *gin.Context) { + c.Set(global.KeyUser, &ent.User{}) + c.Set(KeyRepo, &ent.Repo{}) + }, r.CreateDeployment) + + body, _ := json.Marshal(input.payload) + req, _ := http.NewRequest("POST", "/repos/1/deployments", bytes.NewBuffer(body)) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("Code = %v, wanted %v. Body=%v", w.Code, http.StatusCreated, w.Body) + } + }) +} diff --git a/internal/server/api/v1/repos/repo.go b/internal/server/api/v1/repos/repo.go index d34d7545..e1d8736e 100644 --- a/internal/server/api/v1/repos/repo.go +++ b/internal/server/api/v1/repos/repo.go @@ -28,6 +28,7 @@ type ( repoPatchPayload struct { ConfigPath *string `json:"config_path"` Active *bool `json:"active"` + Locked *bool `json:"locked"` } ) @@ -122,6 +123,18 @@ func (r *Repo) UpdateRepo(c *gin.Context) { } } + if p.Locked != nil { + if *p.Locked != re.Locked { + re.Locked = *p.Locked + + if re, err = r.i.UpdateRepo(ctx, re); err != nil { + r.log.Error("It has failed to update the repo", zap.Error(err)) + gb.ErrorResponse(c, http.StatusInternalServerError, "It has failed to update the repository.") + return + } + } + } + gb.Response(c, http.StatusOK, re) } diff --git a/internal/server/api/v1/repos/repo_test.go b/internal/server/api/v1/repos/repo_test.go index 65f32a0a..af77e849 100644 --- a/internal/server/api/v1/repos/repo_test.go +++ b/internal/server/api/v1/repos/repo_test.go @@ -113,4 +113,52 @@ func TestRepo_UpdateRepo(t *testing.T) { t.Fatalf("Code = %v, wanted %v", w.Code, http.StatusOK) } }) + + t.Run("Patch locked field.", func(t *testing.T) { + input := struct { + payload *repoPatchPayload + }{ + payload: &repoPatchPayload{ + Locked: pointer.ToBool(true), + }, + } + + const ( + r1 = "1" + ) + + ctrl := gomock.NewController(t) + m := mock.NewMockInteractor(ctrl) + + t.Log("Update the repe to set locked field true.") + m. + EXPECT(). + UpdateRepo(gomock.Any(), gomock.Eq(&ent.Repo{ + ID: r1, + Locked: *input.payload.Locked, + })). + DoAndReturn(func(ctx context.Context, r *ent.Repo) (*ent.Repo, error) { + return r, nil + }) + + gin.SetMode(gin.ReleaseMode) + router := gin.New() + + r := NewRepo(RepoConfig{}, m) + router.PATCH("/repos/:id", func(c *gin.Context) { + t.Log("Set up fake middleware") + c.Set(global.KeyUser, &ent.User{}) + c.Set(KeyRepo, &ent.Repo{ID: r1}) + }, r.UpdateRepo) + + p, _ := json.Marshal(input.payload) + req, _ := http.NewRequest("PATCH", fmt.Sprintf("/repos/%s", r1), bytes.NewBuffer(p)) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Code = %v, wanted %v", w.Code, http.StatusOK) + } + }) } diff --git a/internal/server/slack/deploy.go b/internal/server/slack/deploy.go index 3a3c8a7b..267e5a31 100644 --- a/internal/server/slack/deploy.go +++ b/internal/server/slack/deploy.go @@ -297,6 +297,7 @@ func (s *Slack) interactDeploy(c *gin.Context) { // validate values. sm := parseViewSubmissions(itr) + // Validate the entity is processible. _, err := s.getCommitSha(ctx, cu.Edges.User, cb.Edges.Repo, sm.Type, sm.Ref) if vo.IsRefNotFoundError(err) { c.JSON(http.StatusOK, buildErrorsPayload(map[string]string{ @@ -309,6 +310,12 @@ func (s *Slack) interactDeploy(c *gin.Context) { return } + if cb.Edges.Repo.Locked { + postBotMessage(cu, fmt.Sprintf("The `%s` repository is locked. It blocks to deploy.", cb.Edges.Repo.GetFullName())) + c.Status(http.StatusOK) + return + } + cf, err := s.i.GetConfig(ctx, cu.Edges.User, cb.Edges.Repo) if vo.IsConfigNotFoundError(err) { postBotMessage(cu, "The config file is not found.") diff --git a/internal/server/slack/deploy_test.go b/internal/server/slack/deploy_test.go index e0162e16..5a8bbbba 100644 --- a/internal/server/slack/deploy_test.go +++ b/internal/server/slack/deploy_test.go @@ -34,7 +34,11 @@ func TestSlack_interactDeploy(t *testing.T) { m. EXPECT(). FindCallbackByHash(gomock.Any(), callbackID). - Return(&ent.Callback{}, nil) + Return(&ent.Callback{ + Edges: ent.CallbackEdges{ + Repo: &ent.Repo{ID: "1"}, + }, + }, nil) t.Log("Find the chat-user who sent the payload.") m. diff --git a/internal/server/slack/rollback.go b/internal/server/slack/rollback.go index fe460ccd..4d9b1126 100644 --- a/internal/server/slack/rollback.go +++ b/internal/server/slack/rollback.go @@ -256,6 +256,12 @@ func (s *Slack) interactRollback(c *gin.Context) { return } + if cb.Edges.Repo.Locked { + postBotMessage(cu, fmt.Sprintf("The `%s` repository is locked. It blocks to deploy.", cb.Edges.Repo.GetFullName())) + c.Status(http.StatusOK) + return + } + cf, err := s.i.GetConfig(ctx, cu.Edges.User, cb.Edges.Repo) if vo.IsConfigNotFoundError(err) { postBotMessage(cu, "The config file is not found.") diff --git a/openapi.yml b/openapi.yml index e0dda142..e2c51396 100644 --- a/openapi.yml +++ b/openapi.yml @@ -3,7 +3,7 @@ info: title: gitploy. version: '1.0' servers: - - url: http://localhost:8080/api/v1 + - url: http://localhost/api/v1 - url: https://gitploy.jp.ngrok.io/api/v1 paths: /sync: @@ -153,6 +153,8 @@ paths: default: deploy.yml active: type: boolean + locked: + type: boolean parameters: - in: path name: id @@ -1339,6 +1341,8 @@ components: type: boolean webhook_id: type: integer + locked: + type: boolean synced_at: type: string created_at: @@ -1359,6 +1363,7 @@ components: - description - config_path - active + - locked - created_at - updated_at Perms: diff --git a/ui/src/apis/index.ts b/ui/src/apis/index.ts index 53e37ef7..035b1659 100644 --- a/ui/src/apis/index.ts +++ b/ui/src/apis/index.ts @@ -1,14 +1,38 @@ import { sync } from "./sync" -import { listRepos, searchRepo, updateRepo, activateRepo, deactivateRepo } from "./repo" +import { + listRepos, + searchRepo, + updateRepo, + activateRepo, + deactivateRepo, + lockRepo, + unlockRepo, +} from "./repo" import { listPerms } from "./perm" -import { searchDeployments, listDeployments, getDeployment ,createDeployment, updateDeploymentStatusCreated, rollbackDeployment, listDeploymentChanges } from './deployment' +import { + searchDeployments, + listDeployments, + getDeployment, + createDeployment, + updateDeploymentStatusCreated, + rollbackDeployment, + listDeploymentChanges +} from './deployment' import { getConfig } from './config' import { listCommits, getCommit, listStatuses } from './commit' import { listBranches, getBranch } from './branch' import { listTags, getTag } from './tag' import { listUsers, updateUser, deleteUser, getMe, getRateLimit } from "./user" import { checkSlack } from "./chat" -import { searchApprovals, listApprovals, getMyApproval, createApproval, deleteApproval, setApprovalApproved, setApprovalDeclined } from "./approval" +import { + searchApprovals, + listApprovals, + getMyApproval, + createApproval, + deleteApproval, + setApprovalApproved, + setApprovalDeclined +} from "./approval" import { getLicense } from "./license" import { subscribeDeploymentEvent, subscribeApprovalEvent } from "./events" @@ -19,6 +43,8 @@ export { updateRepo, activateRepo, deactivateRepo, + lockRepo, + unlockRepo, listPerms, searchDeployments, listDeployments, diff --git a/ui/src/apis/repo.ts b/ui/src/apis/repo.ts index 65be52c6..52ffbda7 100644 --- a/ui/src/apis/repo.ts +++ b/ui/src/apis/repo.ts @@ -14,7 +14,7 @@ export interface RepoData { config_path: string active: boolean webhook_id: number - synced_at: string + locked: boolean created_at: string updated_at: string edges: { @@ -38,7 +38,7 @@ export const mapDataToRepo = (data: RepoData): Repo => { configPath: data.config_path, active: data.active, webhookId: data.webhook_id, - syncedAt: new Date(data.synced_at), + locked: data.locked, createdAt: new Date(data.created_at), updatedAt: new Date(data.updated_at), deployments, @@ -131,3 +131,45 @@ export const deactivateRepo = async (repo: Repo): Promise => { .then((r:any) => mapDataToRepo(r)) return repo } + +export const lockRepo = async (repo: Repo): Promise => { + const body = { + "locked": true, + } + const response = await _fetch(`${instance}/api/v1/repos/${repo.id}`, { + headers, + credentials: 'same-origin', + method: "PATCH", + body: JSON.stringify(body) + }) + if (response.status === StatusCodes.FORBIDDEN) { + const message = await response.json().then(data => data.message) + throw new HttpForbiddenError(message) + } + + repo = await response + .json() + .then((r:any) => mapDataToRepo(r)) + return repo +} + +export const unlockRepo = async (repo: Repo): Promise => { + const body = { + "locked": false, + } + const response = await _fetch(`${instance}/api/v1/repos/${repo.id}`, { + headers, + credentials: 'same-origin', + method: "PATCH", + body: JSON.stringify(body) + }) + if (response.status === StatusCodes.FORBIDDEN) { + const message = await response.json().then(data => data.message) + throw new HttpForbiddenError(message) + } + + repo = await response + .json() + .then((r:any) => mapDataToRepo(r)) + return repo +} \ No newline at end of file diff --git a/ui/src/components/RepoSettingsForm.tsx b/ui/src/components/RepoSettingsForm.tsx new file mode 100644 index 00000000..e0af18f4 --- /dev/null +++ b/ui/src/components/RepoSettingsForm.tsx @@ -0,0 +1,87 @@ +import { Form, Input, Button, Space } from "antd" +import { Repo } from "../models" + +export interface RepoSettingsFormProps { + repo: Repo + saving: boolean + onClickSave(payload: {configPath: string}): void + onClickLock(): void + onClickUnlock(): void + onClickDeactivate(): void +} + +export default function RepoSettingForm(props: RepoSettingsFormProps): JSX.Element { + const layout = { + labelCol: { span: 5}, + wrapperCol: { span: 12 }, + }; + + const submitLayout = { + wrapperCol: { offset: 5, span: 12 }, + }; + + const onFinish = (values: any) => { + const payload = { + configPath: values.config + } + props.onClickSave(payload) + } + + const values = { + "config": props.repo.configPath + } + + return ( +
+ + + + + + + + + + + + {(props.repo.locked)? + : + + } + + + + +
+ ) +} \ No newline at end of file diff --git a/ui/src/components/SettingsForm.tsx b/ui/src/components/SettingsForm.tsx deleted file mode 100644 index 8883972d..00000000 --- a/ui/src/components/SettingsForm.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Form, Input, Button, Space } from "antd" -import { Repo } from "../models" - -export interface SettingsFormProps { - repo: Repo - saving: boolean - onClickSave(payload: {configPath: string}): void - onClickDeactivate(): void -} - -export default function SettingForm(props: SettingsFormProps): JSX.Element { - const layout = { - labelCol: { span: 5}, - wrapperCol: { span: 12 }, - }; - - const submitLayout = { - wrapperCol: { offset: 5, span: 12 }, - }; - - const onFinish = (values: any) => { - const payload = { - configPath: values.config - } - props.onClickSave(payload) - } - - const values = { - "config": props.repo.configPath - } - - return ( -
- - - - - - - - - - - -
- ) -} \ No newline at end of file diff --git a/ui/src/models/Repo.ts b/ui/src/models/Repo.ts index 5d8aa51c..5f535e71 100644 --- a/ui/src/models/Repo.ts +++ b/ui/src/models/Repo.ts @@ -8,7 +8,7 @@ export default interface Repo { configPath: string active: boolean webhookId: number - syncedAt: Date + locked: boolean createdAt: Date updatedAt: Date deployments?: Deployment[] diff --git a/ui/src/redux/repoSettings.ts b/ui/src/redux/repoSettings.ts index e00ff671..73517da2 100644 --- a/ui/src/redux/repoSettings.ts +++ b/ui/src/redux/repoSettings.ts @@ -1,7 +1,7 @@ import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit" import { message } from "antd" -import { searchRepo, updateRepo, deactivateRepo } from "../apis" +import { searchRepo, updateRepo, deactivateRepo, lockRepo, unlockRepo } from "../apis" import { Repo, RequestStatus, HttpForbiddenError } from "../models" interface RepoSettingsState { @@ -73,6 +73,48 @@ export const deactivate = createAsyncThunk( + 'repoSettings/lock', + async (_, { getState, rejectWithValue } ) => { + const { repo } = getState().repoSettings + if (!repo) throw new Error("There is no repo.") + + try { + const ret = await lockRepo(repo) + message.info("Lock the repository successfully.", 3) + return ret + } catch(e) { + if (e instanceof HttpForbiddenError) { + message.error("Only admin permission can lock the repository.", 3) + } else { + message.error("It has failed to lock.", 3) + } + return rejectWithValue(e) + } + }, +) + +export const unlock = createAsyncThunk( + 'repoSettings/unlock', + async (_, { getState, rejectWithValue } ) => { + const { repo } = getState().repoSettings + if (!repo) throw new Error("There is no repo.") + + try { + const ret = await unlockRepo(repo) + message.info("Unlock the repository successfully.", 3) + return ret + } catch(e) { + if (e instanceof HttpForbiddenError) { + message.error("Only admin permission can unlock the repository.", 3) + } else { + message.error("It has failed to unlock.", 3) + } + return rejectWithValue(e) + } + }, +) + export const repoSettingsSlice = createSlice({ name: "repoSettings", initialState, @@ -116,5 +158,11 @@ export const repoSettingsSlice = createSlice({ .addCase(deactivate.rejected, (state) => { state.deactivating = RequestStatus.Idle }) + .addCase(lock.fulfilled, (state, action) => { + state.repo = action.payload + }) + .addCase(unlock.fulfilled, (state, action) => { + state.repo = action.payload + }) } }) \ No newline at end of file diff --git a/ui/src/views/Deployment.tsx b/ui/src/views/Deployment.tsx index cd444a74..07925af8 100644 --- a/ui/src/views/Deployment.tsx +++ b/ui/src/views/Deployment.tsx @@ -44,6 +44,7 @@ interface Params { export default function DeploymentView(): JSX.Element { const { namespace, name, number } = useParams() const { + repo, deployment, changes, deploying, @@ -151,7 +152,7 @@ export default function DeploymentView(): JSX.Element { - } if (repo && !config) { + } + + if (repo.locked) { + return ( + + ) + } + + if (repo && !config) { return ( - } if (repo && !config) { + } + + if (repo.locked) { + return ( + + ) + } + + if (repo && !config) { return ( { + dispatch(lock()) + } + + const onClickUnlock = () => { + dispatch(unlock()) + } + const onClickDeactivate = () => { dispatch(deactivate()) } @@ -48,11 +56,14 @@ export default function RepoSettings(): JSX.Element { title="Settings"/>
- + onClickLock={onClickLock} + onClickUnlock={onClickUnlock} + onClickDeactivate={onClickDeactivate} + />
)