-
Notifications
You must be signed in to change notification settings - Fork 208
Support multiple manual approvals #2802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
4066174
7d7c088
3a1981f
fbeeb96
3643ad2
3bd9b03
2c8ff73
b6d5e99
ab87d54
e73e886
e511ec0
3c1d901
29e97bf
3f10ecc
ad2fd57
19c2c80
b391a82
9ee377d
ee6792c
f0408d2
a123648
c7d1cdc
64c2361
0074085
650f3bb
8f9a544
c0e6d91
b2ab617
411f7fa
d4a7810
ddf3196
8eeed0a
64f0cea
bcd2ea0
d332a42
c592269
41d336b
1101e89
19fbc7a
7d60a88
9449372
82c2d8e
0c3dce1
ed37174
741101e
2df64f6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -18,6 +18,8 @@ import ( | |||||||||||||||
| "context" | ||||||||||||||||
| "encoding/json" | ||||||||||||||||
| "fmt" | ||||||||||||||||
| "strconv" | ||||||||||||||||
| "strings" | ||||||||||||||||
| "time" | ||||||||||||||||
|
|
||||||||||||||||
| "go.uber.org/zap" | ||||||||||||||||
|
|
@@ -28,7 +30,9 @@ import ( | |||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| const ( | ||||||||||||||||
| approvedByKey = "ApprovedBy" | ||||||||||||||||
| approvedByKey = "ApprovedBy" | ||||||||||||||||
| minApproverNum = "MinApproverNum" | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| approversKey = "CurrentApprovers" | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| type Executor struct { | ||||||||||||||||
|
|
@@ -61,13 +65,33 @@ func (e *Executor) Execute(sig executor.StopSignal) model.StageStatus { | |||||||||||||||
| timer := time.NewTimer(timeout) | ||||||||||||||||
|
|
||||||||||||||||
| e.reportRequiringApproval() | ||||||||||||||||
| e.LogPersister.Info("Waiting for an approval...") | ||||||||||||||||
| n, ok := e.MetadataStore.Stage(e.Stage.Id).Get(minApproverNum) | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| if !ok { | ||||||||||||||||
| e.LogPersister.Errorf("Unabled to retrive %s from metadata", minApproverNum) | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| return model.StageStatus_STAGE_FAILURE | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| num, err := strconv.Atoi(n) | ||||||||||||||||
| if err != nil { | ||||||||||||||||
| e.LogPersister.Errorf("%s could not be converted to integer: %v", num, err) | ||||||||||||||||
| return model.StageStatus_STAGE_FAILURE | ||||||||||||||||
| } | ||||||||||||||||
| if num > 1 { | ||||||||||||||||
| e.LogPersister.Infof("Waiting for an approval from at least %d users...", num) | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| } else { | ||||||||||||||||
| e.LogPersister.Infof("Waiting for an approval from at least %d user...", num) | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| } | ||||||||||||||||
| for { | ||||||||||||||||
| select { | ||||||||||||||||
| case <-ticker.C: | ||||||||||||||||
| if commander, ok := e.checkApproval(ctx); ok { | ||||||||||||||||
| e.reportApproved(commander) | ||||||||||||||||
| e.LogPersister.Infof("Got an approval from %s", commander) | ||||||||||||||||
| if as, ok := e.checkApproval(ctx, num); ok { | ||||||||||||||||
| e.reportApproved(as) | ||||||||||||||||
| approvers := strings.Split(as, ", ") | ||||||||||||||||
| if n := len(approvers); n > 1 { | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| e.LogPersister.Infof("This stage has been approved by %d users (%s)", n, as) | ||||||||||||||||
| } else { | ||||||||||||||||
| e.LogPersister.Infof("This stage has been approved by %d user (%s)", n, as) | ||||||||||||||||
| } | ||||||||||||||||
| return model.StageStatus_STAGE_SUCCESS | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -87,7 +111,7 @@ func (e *Executor) Execute(sig executor.StopSignal) model.StageStatus { | |||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| func (e *Executor) checkApproval(ctx context.Context) (string, bool) { | ||||||||||||||||
| func (e *Executor) checkApproval(ctx context.Context, num int) (string, bool) { | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| var approveCmd *model.ReportableCommand | ||||||||||||||||
| commands := e.CommandLister.ListCommands() | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -101,8 +125,20 @@ func (e *Executor) checkApproval(ctx context.Context) (string, bool) { | |||||||||||||||
| return "", false | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| as, ok := e.validateApproverNum(approveCmd.Commander, num) | ||||||||||||||||
| if !ok { | ||||||||||||||||
| if len(as) > 0 { | ||||||||||||||||
| if err := e.MetadataStore.Stage(e.Stage.Id).Put(ctx, approversKey, as); err != nil { | ||||||||||||||||
| e.LogPersister.Errorf("Unabled to save approver information to deployment, %v", err) | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| return "", false | ||||||||||||||||
| } | ||||||||||||||||
| e.LogPersister.Info("Received all needed approvals") | ||||||||||||||||
| e.LogPersister.Info("") | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
|
|
||||||||||||||||
| metadata := map[string]string{ | ||||||||||||||||
| approvedByKey: approveCmd.Commander, | ||||||||||||||||
| approvedByKey: as, | ||||||||||||||||
| } | ||||||||||||||||
| if err := e.MetadataStore.Stage(e.Stage.Id).PutMulti(ctx, metadata); err != nil { | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| e.LogPersister.Errorf("Unabled to save approver information to deployment, %v", err) | ||||||||||||||||
|
|
@@ -112,7 +148,7 @@ func (e *Executor) checkApproval(ctx context.Context) (string, bool) { | |||||||||||||||
| if err := approveCmd.Report(ctx, model.CommandStatus_COMMAND_SUCCEEDED, nil, nil); err != nil { | ||||||||||||||||
| e.Logger.Error("failed to report handled command", zap.Error(err)) | ||||||||||||||||
| } | ||||||||||||||||
| return approveCmd.Commander, true | ||||||||||||||||
| return as, true | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| func (e *Executor) reportApproved(approver string) { | ||||||||||||||||
|
|
@@ -161,3 +197,28 @@ func (e *Executor) getMentionedAccounts(event model.NotificationEventType) ([]st | |||||||||||||||
|
|
||||||||||||||||
| return notification.FindSlackAccounts(event), nil | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| func (e *Executor) validateApproverNum(approver string, num int) (string, bool) { | ||||||||||||||||
| if num <= 1 { | ||||||||||||||||
| e.LogPersister.Infof("Got approval from \"%s\"", approver) | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| return approver, true | ||||||||||||||||
| } | ||||||||||||||||
| as, ok := e.MetadataStore.Stage(e.Stage.Id).Get(approversKey) | ||||||||||||||||
| if !ok { | ||||||||||||||||
| e.LogPersister.Infof("Got approval from \"%s\"", approver) | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| e.LogPersister.Infof("Waiting for other approvers...") | ||||||||||||||||
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||
| return approver, false | ||||||||||||||||
| } | ||||||||||||||||
|
||||||||||||||||
| as, ok := e.MetadataStore.Stage(e.Stage.Id).Get(approversKey) | |
| if !ok { | |
| e.LogPersister.Infof("Got approval from \"%s\"", approver) | |
| e.LogPersister.Infof("Waiting for other approvers...") | |
| return approver, false | |
| } | |
| as, _ := e.MetadataStore.Stage(e.Stage.Id).Get(approversKey) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure why you wanna delete this part. Could you tell me the reason?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should I apply this change to this PR?
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
ono-max marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
ono-max marked this conversation as resolved.
Show resolved
Hide resolved
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| // Copyright 2021 The PipeCD Authors. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package waitapproval | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "google.golang.org/grpc" | ||
|
|
||
| "github.com/pipe-cd/pipe/pkg/app/api/service/pipedservice" | ||
| "github.com/pipe-cd/pipe/pkg/app/piped/executor" | ||
| "github.com/pipe-cd/pipe/pkg/app/piped/metadatastore" | ||
| "github.com/pipe-cd/pipe/pkg/model" | ||
| ) | ||
|
|
||
| type fakeLogPersister struct{} | ||
|
|
||
| func (l *fakeLogPersister) Write(_ []byte) (int, error) { return 0, nil } | ||
| func (l *fakeLogPersister) Info(_ string) {} | ||
| func (l *fakeLogPersister) Infof(_ string, _ ...interface{}) {} | ||
| func (l *fakeLogPersister) Success(_ string) {} | ||
| func (l *fakeLogPersister) Successf(_ string, _ ...interface{}) {} | ||
| func (l *fakeLogPersister) Error(_ string) {} | ||
| func (l *fakeLogPersister) Errorf(_ string, _ ...interface{}) {} | ||
|
|
||
| type metadata map[string]string | ||
|
|
||
| type fakeAPIClient struct { | ||
| shared metadata | ||
| stages map[string]metadata | ||
| } | ||
|
|
||
| func (c *fakeAPIClient) SaveDeploymentMetadata(_ context.Context, req *pipedservice.SaveDeploymentMetadataRequest, _ ...grpc.CallOption) (*pipedservice.SaveDeploymentMetadataResponse, error) { | ||
| md := make(map[string]string, len(c.shared)+len(req.Metadata)) | ||
| for k, v := range c.shared { | ||
| md[k] = v | ||
| } | ||
| for k, v := range req.Metadata { | ||
| md[k] = v | ||
| } | ||
| c.shared = md | ||
| return &pipedservice.SaveDeploymentMetadataResponse{}, nil | ||
| } | ||
|
|
||
| func (c *fakeAPIClient) SaveStageMetadata(_ context.Context, req *pipedservice.SaveStageMetadataRequest, _ ...grpc.CallOption) (*pipedservice.SaveStageMetadataResponse, error) { | ||
| ori := c.stages[req.StageId] | ||
| md := make(map[string]string, len(ori)+len(req.Metadata)) | ||
| for k, v := range ori { | ||
| md[k] = v | ||
| } | ||
| for k, v := range req.Metadata { | ||
| md[k] = v | ||
| } | ||
| c.stages[req.StageId] = md | ||
| return &pipedservice.SaveStageMetadataResponse{}, nil | ||
| } | ||
|
|
||
| func TestValidateApproverNum(t *testing.T) { | ||
| ac := &fakeAPIClient{ | ||
| shared: make(map[string]string, 0), | ||
| stages: make(map[string]metadata, 0), | ||
| } | ||
| testcases := []struct { | ||
| name string | ||
| approver string | ||
| minApproverNum int | ||
| executor *Executor | ||
| wantBool bool | ||
| wantApprovers string | ||
| }{ | ||
| { | ||
| name: "return the person who just approved it and true", | ||
| approver: "user-1", | ||
| minApproverNum: 0, | ||
| executor: &Executor{ | ||
| Input: executor.Input{ | ||
| Stage: &model.PipelineStage{ | ||
| Id: "stage-1", | ||
| }, | ||
| LogPersister: &fakeLogPersister{}, | ||
| MetadataStore: metadatastore.NewMetadataStore(ac, &model.Deployment{ | ||
| Stages: []*model.PipelineStage{ | ||
| { | ||
| Id: "stage-1", | ||
| Metadata: map[string]string{}, | ||
| }, | ||
| }, | ||
| }), | ||
| }, | ||
| }, | ||
| wantBool: true, | ||
| wantApprovers: "user-1", | ||
| }, | ||
| { | ||
| name: "return the person who just approved it and false", | ||
| approver: "user-1", | ||
| minApproverNum: 2, | ||
| executor: &Executor{ | ||
| Input: executor.Input{ | ||
| Stage: &model.PipelineStage{ | ||
| Id: "stage-1", | ||
| }, | ||
| LogPersister: &fakeLogPersister{}, | ||
| MetadataStore: metadatastore.NewMetadataStore(ac, &model.Deployment{ | ||
| Stages: []*model.PipelineStage{ | ||
| { | ||
| Id: "stage-1", | ||
| Metadata: map[string]string{}, | ||
| }, | ||
| }, | ||
| }), | ||
| }, | ||
| }, | ||
| wantBool: false, | ||
| wantApprovers: "user-1", | ||
| }, | ||
| { | ||
| name: "return nothing and false", | ||
| approver: "user-1", | ||
| minApproverNum: 2, | ||
| executor: &Executor{ | ||
| Input: executor.Input{ | ||
| Stage: &model.PipelineStage{ | ||
| Id: "stage-1", | ||
| }, | ||
| LogPersister: &fakeLogPersister{}, | ||
| MetadataStore: metadatastore.NewMetadataStore(ac, &model.Deployment{ | ||
| Stages: []*model.PipelineStage{ | ||
| { | ||
| Id: "stage-1", | ||
| Metadata: map[string]string{ | ||
| approversKey: "user-1", | ||
| }, | ||
| }, | ||
| }, | ||
| }), | ||
| }, | ||
| }, | ||
| wantBool: false, | ||
| wantApprovers: "", | ||
| }, | ||
| { | ||
| name: "return all approvers and false", | ||
| approver: "user-2", | ||
| minApproverNum: 3, | ||
| executor: &Executor{ | ||
| Input: executor.Input{ | ||
| Stage: &model.PipelineStage{ | ||
| Id: "stage-1", | ||
| }, | ||
| LogPersister: &fakeLogPersister{}, | ||
| MetadataStore: metadatastore.NewMetadataStore(ac, &model.Deployment{ | ||
| Stages: []*model.PipelineStage{ | ||
| { | ||
| Id: "stage-1", | ||
| Metadata: map[string]string{ | ||
| approversKey: "user-1", | ||
| }, | ||
| }, | ||
| }, | ||
| }), | ||
| }, | ||
| }, | ||
| wantBool: false, | ||
| wantApprovers: "user-1, user-2", | ||
| }, | ||
| { | ||
| name: "return all approvers and true", | ||
| approver: "user-2", | ||
| minApproverNum: 2, | ||
| executor: &Executor{ | ||
| Input: executor.Input{ | ||
| Stage: &model.PipelineStage{ | ||
| Id: "stage-1", | ||
| }, | ||
| LogPersister: &fakeLogPersister{}, | ||
| MetadataStore: metadatastore.NewMetadataStore(ac, &model.Deployment{ | ||
| Stages: []*model.PipelineStage{ | ||
| { | ||
| Id: "stage-1", | ||
| Metadata: map[string]string{ | ||
| approversKey: "user-1", | ||
| }, | ||
| }, | ||
| }, | ||
| }), | ||
| }, | ||
| }, | ||
| wantBool: true, | ||
| wantApprovers: "user-1, user-2", | ||
| }, | ||
| } | ||
| for _, tc := range testcases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| as, ok := tc.executor.validateApproverNum(tc.approver, tc.minApproverNum) | ||
| assert.Equal(t, tc.wantBool, ok) | ||
| assert.Equal(t, tc.wantApprovers, as) | ||
| }) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.