Provisioning: Fix stage options for export (#108585)

* Add ref option to stage options

* Fix the issue with ref in export worker

* Add unit tests for export stage options

* Do not fail if ref is equal to the stage branch

* Format code again

* fix test

---------

Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>
This commit is contained in:
Roberto Jiménez Sánchez
2025-07-24 16:53:33 +00:00
committed by GitHub
co-authored by Stephanie Hingtgen
parent 6eb400d1a7
commit e9b9618fb0
5 changed files with 260 additions and 22 deletions
@@ -62,9 +62,11 @@ func (r *ExportWorker) Process(ctx context.Context, repo repository.Repository,
}
cloneOptions := repository.StageOptions{
Ref: options.Branch,
Timeout: 10 * time.Minute,
PushOnWrites: false,
Mode: repository.StageModeCommitOnlyOnce,
CommitOnlyOnceMessage: msg,
Timeout: 10 * time.Minute,
}
fn := func(repo repository.Repository, _ bool) error {
@@ -228,7 +228,9 @@ func TestExportWorker_ProcessStageOptions(t *testing.T) {
job := v0alpha1.Job{
Spec: v0alpha1.JobSpec{
Action: v0alpha1.JobActionPush,
Push: &v0alpha1.ExportJobOptions{},
Push: &v0alpha1.ExportJobOptions{
Branch: "feature-branch",
},
},
}
@@ -239,7 +241,8 @@ func TestExportWorker_ProcessStageOptions(t *testing.T) {
Namespace: "test-namespace",
},
Spec: v0alpha1.RepositorySpec{
Workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow},
Type: v0alpha1.GitRepositoryType,
Workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow, v0alpha1.BranchWorkflow},
},
})
@@ -258,9 +261,11 @@ func TestExportWorker_ProcessStageOptions(t *testing.T) {
mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
mockStageFn := NewMockWrapWithStageFn(t)
// Verify clone and push options
// Verify all stage options including Ref (branch), Timeout, and PushOnWrites
mockStageFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.StageOptions) bool {
return opts.Timeout == 10*time.Minute && opts.Mode == repository.StageModeCommitOnlyOnce
return opts.Ref == "feature-branch" &&
opts.Timeout == 10*time.Minute &&
!opts.PushOnWrites && opts.Mode == repository.StageModeCommitOnlyOnce
}), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
@@ -270,6 +275,89 @@ func TestExportWorker_ProcessStageOptions(t *testing.T) {
require.NoError(t, err)
}
func TestExportWorker_ProcessStageOptionsWithBranch(t *testing.T) {
tests := []struct {
name string
branch string
expectedRef string
workflows []v0alpha1.Workflow
repoType v0alpha1.RepositoryType
}{
{
name: "branch specified",
branch: "develop",
expectedRef: "develop",
workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow, v0alpha1.BranchWorkflow},
repoType: v0alpha1.GitRepositoryType,
},
{
name: "empty branch",
branch: "",
expectedRef: "",
workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow},
repoType: v0alpha1.LocalRepositoryType,
},
{
name: "main branch",
branch: "main",
expectedRef: "main",
workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow, v0alpha1.BranchWorkflow},
repoType: v0alpha1.GitRepositoryType,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
job := v0alpha1.Job{
Spec: v0alpha1.JobSpec{
Action: v0alpha1.JobActionPush,
Push: &v0alpha1.ExportJobOptions{
Branch: tt.branch,
},
},
}
mockRepo := repository.NewMockRepository(t)
mockRepo.On("Config").Return(&v0alpha1.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "test-namespace",
},
Spec: v0alpha1.RepositorySpec{
Type: tt.repoType,
Workflows: tt.workflows,
},
})
mockProgress := jobs.NewMockJobProgressRecorder(t)
mockClients := resources.NewMockClientFactory(t)
mockResourceClients := resources.NewMockResourceClients(t)
mockClients.On("Clients", mock.Anything, "test-namespace").Return(mockResourceClients, nil)
mockRepoResources := resources.NewMockRepositoryResourcesFactory(t)
mockRepoResourcesClient := resources.NewMockRepositoryResources(t)
mockRepoResources.On("Client", mock.Anything, mock.Anything).Return(mockRepoResourcesClient, nil)
mockExportFn := NewMockExportFn(t)
mockExportFn.On("Execute", mock.Anything, "test-repo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
mockStageFn := NewMockWrapWithStageFn(t)
// Verify that the stage options contain the correct branch reference and other parameters
mockStageFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.StageOptions) bool {
return opts.Ref == tt.expectedRef &&
opts.Timeout == 10*time.Minute &&
!opts.PushOnWrites
}), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, stageOpts repository.StageOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockStageFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
})
}
}
func TestExportWorker_ProcessExportFnError(t *testing.T) {
job := v0alpha1.Job{
Spec: v0alpha1.JobSpec{
@@ -26,7 +26,12 @@ func NewStagedGitRepository(ctx context.Context, repo *gitRepository, opts repos
defer cancel()
}
ref, err := repo.client.GetRef(ctx, "refs/heads/"+repo.gitConfig.Branch)
branch := opts.Ref
if branch == "" {
branch = repo.gitConfig.Branch
}
ref, err := repo.client.GetRef(ctx, "refs/heads/"+branch)
if err != nil {
// TODO: opts.CreateIfNotExists doesn't make sense in the context of the staged repository
// because we only support the branch that is passed in.
@@ -47,8 +52,25 @@ func NewStagedGitRepository(ctx context.Context, repo *gitRepository, opts repos
}, nil
}
// isRefSupported checks if the given ref is supported for staged operations.
// It returns true if ref is empty, equals the git config branch, or equals the staged options ref.
func (r *stagedGitRepository) isRefSupported(ref string) bool {
if ref == "" {
return true
}
if ref == r.gitConfig.Branch {
return true
}
// Allow ref if it matches the staged options ref (the branch we're staging to)
stagingBranch := r.opts.Ref
if stagingBranch == "" {
stagingBranch = r.gitConfig.Branch
}
return ref == stagingBranch
}
func (r *stagedGitRepository) Read(ctx context.Context, path, ref string) (*repository.FileInfo, error) {
if ref != "" && ref != r.gitConfig.Branch {
if !r.isRefSupported(ref) {
return nil, errors.New("ref is not supported for staged repository")
}
@@ -58,7 +80,7 @@ func (r *stagedGitRepository) Read(ctx context.Context, path, ref string) (*repo
}
func (r *stagedGitRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
if ref != "" && ref != r.gitConfig.Branch {
if !r.isRefSupported(ref) {
return nil, errors.New("ref is not supported for staged repository")
}
@@ -89,7 +111,7 @@ func (r *stagedGitRepository) handleCommitAndPush(ctx context.Context, message s
}
func (r *stagedGitRepository) Create(ctx context.Context, path, ref string, data []byte, message string) error {
if ref != "" && ref != r.gitConfig.Branch {
if !r.isRefSupported(ref) {
return errors.New("ref is not supported for staged repository")
}
@@ -108,7 +130,7 @@ func (r *stagedGitRepository) blobExists(ctx context.Context, path string) (bool
}
func (r *stagedGitRepository) Write(ctx context.Context, path, ref string, data []byte, message string) error {
if ref != "" && ref != r.gitConfig.Branch {
if !r.isRefSupported(ref) {
return errors.New("ref is not supported for staged repository")
}
@@ -131,7 +153,7 @@ func (r *stagedGitRepository) Write(ctx context.Context, path, ref string, data
}
func (r *stagedGitRepository) Update(ctx context.Context, path, ref string, data []byte, message string) error {
if ref != "" && ref != r.gitConfig.Branch {
if !r.isRefSupported(ref) {
return errors.New("ref is not supported for staged repository")
}
@@ -147,7 +169,7 @@ func (r *stagedGitRepository) Update(ctx context.Context, path, ref string, data
}
func (r *stagedGitRepository) Delete(ctx context.Context, path, ref, message string) error {
if ref != "" && ref != r.gitConfig.Branch {
if !r.isRefSupported(ref) {
return errors.New("ref is not supported for staged repository")
}
@@ -17,10 +17,11 @@ import (
func TestNewStagedGitRepository(t *testing.T) {
tests := []struct {
name string
setupMock func(*mocks.FakeClient)
opts repository.StageOptions
wantError error
name string
setupMock func(*mocks.FakeClient)
opts repository.StageOptions
wantError error
expectedRef string
}{
{
name: "succeeds with default options",
@@ -32,11 +33,29 @@ func TestNewStagedGitRepository(t *testing.T) {
mockWriter := &mocks.FakeStagedWriter{}
mockClient.NewStagedWriterReturns(mockWriter, nil)
},
expectedRef: "refs/heads/main",
opts: repository.StageOptions{
Mode: repository.StageModeCommitOnEach,
},
wantError: nil,
},
{
name: "succeeds with custom ref option",
setupMock: func(mockClient *mocks.FakeClient) {
mockClient.GetRefReturns(nanogit.Ref{
Name: "refs/heads/custom",
Hash: hash.Hash{1, 2, 3},
}, nil)
mockWriter := &mocks.FakeStagedWriter{}
mockClient.NewStagedWriterReturns(mockWriter, nil)
},
expectedRef: "refs/heads/custom",
opts: repository.StageOptions{
Ref: "custom",
PushOnWrites: false,
},
wantError: nil,
},
{
name: "succeeds with BeforeFn",
setupMock: func(mockClient *mocks.FakeClient) {
@@ -50,7 +69,8 @@ func TestNewStagedGitRepository(t *testing.T) {
opts: repository.StageOptions{
Mode: repository.StageModeCommitOnEach,
},
wantError: nil,
wantError: nil,
expectedRef: "refs/heads/main",
},
{
name: "succeeds with timeout",
@@ -66,7 +86,8 @@ func TestNewStagedGitRepository(t *testing.T) {
Mode: repository.StageModeCommitOnEach,
Timeout: time.Second * 5,
},
wantError: nil,
expectedRef: "refs/heads/main",
wantError: nil,
},
{
name: "succeeds with CommitOnlyOnce option",
@@ -82,7 +103,8 @@ func TestNewStagedGitRepository(t *testing.T) {
Mode: repository.StageModeCommitOnlyOnce,
CommitOnlyOnceMessage: "Custom commit message",
},
wantError: nil,
expectedRef: "refs/heads/main",
wantError: nil,
},
{
name: "succeeds with CommitAndPushOnEach option",
@@ -97,7 +119,8 @@ func TestNewStagedGitRepository(t *testing.T) {
opts: repository.StageOptions{
Mode: repository.StageModeCommitAndPushOnEach,
},
wantError: nil,
expectedRef: "refs/heads/main",
wantError: nil,
},
{
name: "fails with GetRef error",
@@ -121,7 +144,8 @@ func TestNewStagedGitRepository(t *testing.T) {
opts: repository.StageOptions{
Mode: repository.StageModeCommitOnEach,
},
wantError: errors.New("build staged writer: failed to create writer"),
wantError: errors.New("build staged writer: failed to create writer"),
expectedRef: "refs/heads/main",
},
}
@@ -156,6 +180,10 @@ func TestNewStagedGitRepository(t *testing.T) {
require.Equal(t, tt.opts.Mode, actualOpts.Mode)
require.Equal(t, tt.opts.Timeout, actualOpts.Timeout)
require.Equal(t, tt.opts.CommitOnlyOnceMessage, actualOpts.CommitOnlyOnceMessage)
// Verify the expected ref
_, ref := mockClient.GetRefArgsForCall(0)
require.Equal(t, tt.expectedRef, ref)
}
})
}
@@ -207,6 +235,25 @@ func TestStagedGitRepository_Read(t *testing.T) {
ref: "main",
wantError: nil,
},
{
name: "succeeds with ref matching stage options",
setupMock: func(mockClient *mocks.FakeClient) {
mockClient.GetRefReturns(nanogit.Ref{
Name: "refs/heads/feature",
Hash: hash.Hash{1, 2, 3},
}, nil)
mockClient.GetCommitReturns(&nanogit.Commit{
Tree: hash.Hash{4, 5, 6},
}, nil)
mockClient.GetBlobByPathReturns(&nanogit.Blob{
Content: []byte("file content"),
Hash: hash.Hash{7, 8, 9},
}, nil)
},
path: "test.yaml",
ref: "feature",
wantError: nil,
},
{
name: "fails with unsupported ref",
setupMock: func(_ *mocks.FakeClient) {
@@ -223,7 +270,13 @@ func TestStagedGitRepository_Read(t *testing.T) {
mockClient := &mocks.FakeClient{}
tt.setupMock(mockClient)
stagedRepo := createTestStagedRepository(mockClient)
// Use stage options with ref "feature" for the specific test case
opts := repository.StageOptions{}
if tt.ref == "feature" {
opts.Ref = "feature"
}
stagedRepo := createTestStagedRepositoryWithWriter(&mocks.FakeStagedWriter{}, opts, mockClient)
fileInfo, err := stagedRepo.Read(context.Background(), tt.path, tt.ref)
if tt.wantError != nil {
require.EqualError(t, err, tt.wantError.Error())
@@ -986,6 +1039,75 @@ func TestStagedGitRepository_Remove(t *testing.T) {
})
}
func TestStagedGitRepository_isRefSupported(t *testing.T) {
tests := []struct {
name string
stageOpts repository.StageOptions
gitBranch string
ref string
expected bool
}{
{
name: "empty ref is supported",
stageOpts: repository.StageOptions{},
gitBranch: "main",
ref: "",
expected: true,
},
{
name: "ref matches git config branch",
stageOpts: repository.StageOptions{},
gitBranch: "main",
ref: "main",
expected: true,
},
{
name: "ref matches stage options ref",
stageOpts: repository.StageOptions{Ref: "feature"},
gitBranch: "main",
ref: "feature",
expected: true,
},
{
name: "ref matches stage options ref when empty defaults to git branch",
stageOpts: repository.StageOptions{Ref: ""},
gitBranch: "main",
ref: "main",
expected: true,
},
{
name: "unsupported ref",
stageOpts: repository.StageOptions{Ref: "feature"},
gitBranch: "main",
ref: "other-branch",
expected: false,
},
{
name: "unsupported ref with empty stage options",
stageOpts: repository.StageOptions{},
gitBranch: "main",
ref: "feature",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stagedRepo := &stagedGitRepository{
gitRepository: &gitRepository{
gitConfig: RepositoryConfig{
Branch: tt.gitBranch,
},
},
opts: tt.stageOpts,
}
result := stagedRepo.isRefSupported(tt.ref)
require.Equal(t, tt.expected, result)
})
}
}
// Helper functions for creating test instances
func createTestStagedRepository(mockClient *mocks.FakeClient) *stagedGitRepository {
@@ -23,6 +23,10 @@ const (
)
type StageOptions struct {
// Ref custom ref
Ref string
// Push on every write
PushOnWrites bool
// Mode defines the staging and commit behavior
Mode StageMode
// Maximum time allowed for clone operation in seconds (0 means no limit)