Skip to content

Commit

Permalink
Support skipping stages by path pattern or commit message prefix (#4922)
Browse files Browse the repository at this point in the history
* rename func to distinguish the trigger of skip stage

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Draft: impl check of skipping stage

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Rename to 'prefixes'

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* separate to sub funcs

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Impl Repo.GetCommitFromRev

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Impl skipByPathPattern()

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Add skipOn options to Wait Stage Options

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Add check of skipping stage in Analysis,Wait,WaitApproval Stages

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Fix comments

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* draft: add an empty test file

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* fix comments

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Fix func name

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Rename struct from confusing one

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Refine order of args

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* add test of hasOnlyPathsToSkip

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Refine testcase names

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* rename 'expected' to 'skip'

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Fix testcases

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* separate func

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* add test for skipByCommitMessagePrefixes

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Moved package to new 'skipstage'

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Add SkipOptions to ScriptRunStageOptions

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* rename 'SkipOptions' to 'SkipOn' to match with yaml

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Move skip logic to scheduler

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Add missing reporting

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* fix nits

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Fix check logic

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Add missing error handling

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Move skipstage to controller

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Update docs

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* gen mock by 'make gen/code'

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* nits: fix from 'fromCmd' to 'byCmd'

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* nits: rename func

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* nits: refine configuration-reference

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* fix error logging of zap.Error(err)

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* fix the flow in error handling

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Fix: check skipping before creating executor

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Remove unused func 'GetCommitHashForRev'

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* fix: remove unnecessary formatting

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* refactor: quit separating 'skipstage' package

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* fix: make 'checkSkipStage()' private after refactoring

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Fix: rename test func

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Refactor: merge func 'checkSkipStage' into 'shouldSkipStage'

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Merge funcs

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Add TestSkipByCommitMessagePrefixes with mock

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

* Refactore: merge funcs and use mock

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>

---------

Signed-off-by: t-kikuc <tkikuchi07f@gmail.com>
  • Loading branch information
t-kikuc committed Jun 18, 2024
1 parent 9b82e65 commit e978760
Show file tree
Hide file tree
Showing 9 changed files with 367 additions and 51 deletions.
11 changes: 11 additions & 0 deletions docs/content/en/docs-dev/user-guide/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,13 @@ Note: The available values are identical to those found in the aws-sdk-go-v2 Typ
| Field | Type | Description | Required |
|-|-|-|-|

## SkipOptions

| Field | Type | Description | Required |
|-|-|-|-|
| commitMessagePrefixes | []string | List of commit message's prefixes. The stage will be skipped when the prefix of the commit's message matches any of them. Empty means the stage will not be skipped by this condition. | No |
| paths | []string | List of paths to directories or files. When all commit changes match them, the stage will be skipped. Empty means the stage will not be skipped by this condition. Regular expression can be used. | No |

## StageOptions

### KubernetesPrimaryRolloutStageOptions
Expand Down Expand Up @@ -705,12 +712,14 @@ Note: By default, the sum of traffic is rounded to 100. If both `primary` and `c
|-|-|-|-|
| duration | duration | Maximum time to perform the analysis. | Yes |
| metrics | [][AnalysisMetrics](#analysismetrics) | Configuration for analysis by metrics. | No |
| skipOn | [SkipOptions](#skipoptions) | When to skip this stage. | No |

### WaitStageOptions

| Field | Type | Description | Required |
|-|-|-|-|
| duration | duration | Time to wait. | Yes |
| skipOn | [SkipOptions](#skipoptions) | When to skip this stage. | No |

### WaitApprovalStageOptions

Expand All @@ -719,6 +728,7 @@ Note: By default, the sum of traffic is rounded to 100. If both `primary` and `c
| timeout | duration | The maximum length of time to wait before giving up. Default is 6h. | No |
| approvers | []string | List of username who has permission to approve. | Yes |
| minApproverNum | int | Number of minimum needed approvals to make this stage complete. Default is 1. | No |
| skipOn | [SkipOptions](#skipoptions) | When to skip this stage. | No |

### CustomSyncStageOptions (deprecated)
| Field | Type | Description | Required |
Expand All @@ -733,6 +743,7 @@ Note: By default, the sum of traffic is rounded to 100. If both `primary` and `c
| run | string | Script run on this stage. | Yes |
| env | map[string]string | Environment variables used with scripts. | No |
| timeout | duration | The maximum time the stage can be taken to run. Default is `6h`| No |
| skipOn | [SkipOptions](#skipoptions) | When to skip this stage. | No |

## PostSync

Expand Down
18 changes: 18 additions & 0 deletions pkg/app/piped/controller/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,24 @@ func (s *scheduler) executeStage(sig executor.StopSignal, ps model.PipelineStage
Notifier: s.notifier,
}

// Skip the stage if needed based on the skip config.
skip, err := s.shouldSkipStage(sig.Context(), input)
if err != nil {
lp.Errorf("failed to check whether skipping the stage: %w", err.Error())
if err := s.reportStageStatus(ctx, ps.Id, model.StageStatus_STAGE_FAILURE, ps.Requires); err != nil {
s.logger.Error("failed to report stage status", zap.Error(err))
}
return model.StageStatus_STAGE_FAILURE
}
if skip {
if err := s.reportStageStatus(ctx, ps.Id, model.StageStatus_STAGE_SKIPPED, ps.Requires); err != nil {
s.logger.Error("failed to report stage status", zap.Error(err))
return model.StageStatus_STAGE_FAILURE
}
lp.Info("The stage was successfully skipped due to the skip configuration of the stage.")
return model.StageStatus_STAGE_SKIPPED
}

// Find the executor for this stage.
ex, ok := executorFactory(input)
if !ok {
Expand Down
113 changes: 113 additions & 0 deletions pkg/app/piped/controller/skipstage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright 2024 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 controller

import (
"context"
"strings"

"github.com/pipe-cd/pipecd/pkg/app/piped/executor"
"github.com/pipe-cd/pipecd/pkg/config"
"github.com/pipe-cd/pipecd/pkg/filematcher"
"github.com/pipe-cd/pipecd/pkg/git"
"github.com/pipe-cd/pipecd/pkg/model"
)

// checkSkipStage checks whether the stage should be skipped or not.
func (s *scheduler) shouldSkipStage(ctx context.Context, in executor.Input) (skip bool, err error) {
stageConfig := in.StageConfig
var skipOptions config.SkipOptions
switch stageConfig.Name {
case model.StageAnalysis:
skipOptions = stageConfig.AnalysisStageOptions.SkipOn
case model.StageWait:
skipOptions = stageConfig.WaitStageOptions.SkipOn
case model.StageWaitApproval:
skipOptions = stageConfig.WaitApprovalStageOptions.SkipOn
case model.StageScriptRun:
skipOptions = stageConfig.ScriptRunStageOptions.SkipOn
default:
return false, nil
}

if len(skipOptions.Paths) == 0 && len(skipOptions.CommitMessagePrefixes) == 0 {
// When no condition is specified.
return false, nil
}

repoCfg := in.Application.GitPath.Repo
repo, err := in.GitClient.Clone(ctx, repoCfg.Id, repoCfg.Remote, repoCfg.Branch, "")
if err != nil {
return false, err
}

// Check by path pattern
skip, err = skipByPathPattern(ctx, skipOptions, repo, in.RunningDSP.Revision(), in.TargetDSP.Revision())
if err != nil {
return false, err
}
if skip {
return true, nil
}

// Check by prefix of commit message
skip, err = skipByCommitMessagePrefixes(ctx, skipOptions, repo, in.TargetDSP.Revision())
return skip, err
}

// skipByCommitMessagePrefixes returns true if the commit message has ANY one of the specified prefixes.
func skipByCommitMessagePrefixes(ctx context.Context, opt config.SkipOptions, repo git.Repo, targetRev string) (skip bool, err error) {
if len(opt.CommitMessagePrefixes) == 0 {
return false, nil
}

commit, err := repo.GetCommitForRev(ctx, targetRev)
if err != nil {
return false, err
}

for _, prefix := range opt.CommitMessagePrefixes {
if strings.HasPrefix(commit.Message, prefix) {
return true, nil
}
}
return false, nil
}

// skipByPathPattern returns true if and only if ALL changed files are included in `opt.Paths`.
// If ANY changed file does not match all `skipPatterns`, it returns false.
func skipByPathPattern(ctx context.Context, opt config.SkipOptions, repo git.Repo, runningRev, targetRev string) (skip bool, err error) {
if len(opt.Paths) == 0 {
return false, nil
}

changedFiles, err := repo.ChangedFiles(ctx, runningRev, targetRev)
if err != nil {
return false, err
}

matcher, err := filematcher.NewPatternMatcher(opt.Paths)
if err != nil {
return false, err
}

for _, changedFile := range changedFiles {
if !matcher.Matches(changedFile) {
return false, nil
}
}

return true, nil
}
157 changes: 157 additions & 0 deletions pkg/app/piped/controller/skipstage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright 2024 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 controller

import (
"context"
"testing"

"github.com/golang/mock/gomock"
"github.com/pipe-cd/pipecd/pkg/config"
"github.com/pipe-cd/pipecd/pkg/git"
"github.com/pipe-cd/pipecd/pkg/git/gittest"
"github.com/stretchr/testify/assert"
)

func TestSkipByCommitMessagePrefixes(t *testing.T) {
t.Parallel()
testcases := []struct {
name string
commitMessage string
prefixes []string
skip bool
}{
{
name: "no prefixes",
commitMessage: "test message",
prefixes: []string{},
skip: false,
},
{
name: "no commit message",
commitMessage: "",
prefixes: []string{"to-skip"},
skip: false,
},
{
name: "prefix matches",
commitMessage: "to-skip: test message",
prefixes: []string{"to-skip"},
skip: true,
},
}

for _, tc := range testcases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
repoMock := gittest.NewMockRepo(ctrl)
repoMock.EXPECT().GetCommitForRev(gomock.Any(), gomock.Any()).Return(git.Commit{
Message: tc.commitMessage,
}, nil).AnyTimes()

opt := config.SkipOptions{
CommitMessagePrefixes: tc.prefixes,
}
skip, err := skipByCommitMessagePrefixes(context.Background(), opt, repoMock, "test-rev")
assert.Equal(t, tc.skip, skip)
assert.NoError(t, err)
})
}
}

func TestSkipByPathPattern(t *testing.T) {
t.Parallel()
testcases := []struct {
name string
skipPatterns []string
changedFiles []string
skip bool
}{
{
name: "no skip patterns",
skipPatterns: nil,
changedFiles: []string{"file1"},
skip: false,
},
{
name: "no changed files",
skipPatterns: []string{"file1"},
changedFiles: nil,
skip: true,
},
{
name: "no skip patterns and no changed files",
skipPatterns: nil,
changedFiles: nil,
skip: false,
},
{
name: "skip pattern matches all changed files",
skipPatterns: []string{"file1", "file2"},
changedFiles: []string{"file1", "file2"},
skip: true,
},
{
name: "skip pattern does not match changed files",
skipPatterns: []string{"file1", "file2"},
changedFiles: []string{"file1", "file3"},
skip: false,
},
{
name: "skip files of a directory",
skipPatterns: []string{"dir1/*"},
changedFiles: []string{"dir1/file1", "dir1/file2"},
skip: true,
},
{
name: "skip files recursively",
skipPatterns: []string{"dir1/**"},
changedFiles: []string{"dir1/file1", "dir1/sub/file2"},
skip: true,
},
{
name: "skip files with the extension recursively",
skipPatterns: []string{"dir1/**/*.yaml"},
changedFiles: []string{"dir1/file1.yaml", "dir1/sub1/file2.yaml", "dir1/sub1/sub2/file3.yaml"},
skip: true,
},
{
name: "skip files not recursively",
skipPatterns: []string{"*.yaml"},
changedFiles: []string{"dir1/file1.yaml"},
skip: false,
},
}

for _, tc := range testcases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
// We do not use t.Parallel() here due to https://pkg.go.dev/github.com/pipe-cd/pipecd/pkg/filematcher#PatternMatcher.Matches.
ctrl := gomock.NewController(t)
defer ctrl.Finish()
repoMock := gittest.NewMockRepo(ctrl)
repoMock.EXPECT().ChangedFiles(gomock.Any(), gomock.Any(), gomock.Any()).Return(tc.changedFiles, nil).AnyTimes()

opt := config.SkipOptions{
Paths: tc.skipPatterns,
}
actual, err := skipByPathPattern(context.Background(), opt, repoMock, "running-rev", "target-rev")
assert.NoError(t, err)
assert.Equal(t, tc.skip, actual)
})
}
}
4 changes: 2 additions & 2 deletions pkg/app/piped/executor/analysis/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func (e *Executor) Execute(sig executor.StopSignal) model.StageStatus {
for {
select {
case <-ticker.C:
if !e.checkSkipped(ctx) {
if !e.checkSkippedByCmd(ctx) {
continue
}
status = model.StageStatus_STAGE_SKIPPED
Expand Down Expand Up @@ -339,7 +339,7 @@ func (e *Executor) buildAppArgs(customArgs map[string]string) argsTemplate {
return args
}

func (e *Executor) checkSkipped(ctx context.Context) bool {
func (e *Executor) checkSkippedByCmd(ctx context.Context) bool {
var skipCmd *model.ReportableCommand
commands := e.CommandLister.ListCommands()

Expand Down
Loading

0 comments on commit e978760

Please sign in to comment.