Provisioning: Add pure git repository type (#106815)

* Add repository type git to spec
* Register git type
* Update test checks
This commit is contained in:
Roberto Jiménez Sánchez
2025-06-18 09:05:37 +02:00
committed by GitHub
parent 806068b9de
commit 3cb62e370b
16 changed files with 679 additions and 20 deletions
@@ -381,3 +381,108 @@ func TestExportWorker_ProcessBranchNotAllowedForClonableRepositories(t *testing.
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "branch is not supported for clonable repositories")
}
func TestExportWorker_ProcessGitRepository(t *testing.T) {
job := v0alpha1.Job{
Spec: v0alpha1.JobSpec{
Action: v0alpha1.JobActionPush,
Push: &v0alpha1.ExportJobOptions{},
},
}
mockRepo := repository.NewMockRepository(t)
mockRepo.On("Config").Return(&v0alpha1.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "test-namespace",
},
Spec: v0alpha1.RepositorySpec{
Type: v0alpha1.GitRepositoryType,
Git: &v0alpha1.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
},
Workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow},
},
})
mockProgress := jobs.NewMockJobProgressRecorder(t)
// Verify progress messages are set
mockProgress.On("SetMessage", mock.Anything, "clone target").Return()
mockProgress.On("SetMessage", mock.Anything, "push changes").Return()
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)
mockCloneFn := NewMockWrapWithCloneFn(t)
// Verify clone and push options
mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.MatchedBy(func(opts repository.CloneOptions) bool {
return opts.Timeout == 10*time.Minute && !opts.PushOnWrites && opts.BeforeFn != nil
}), mock.MatchedBy(func(opts repository.PushOptions) bool {
return opts.Timeout == 10*time.Minute && opts.Progress != nil && opts.BeforeFn != nil
}), mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
// Execute both BeforeFn functions to verify progress messages
assert.NoError(t, cloneOpts.BeforeFn())
assert.NoError(t, pushOpts.BeforeFn())
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockCloneFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.NoError(t, err)
}
func TestExportWorker_ProcessGitRepositoryExportFnError(t *testing.T) {
job := v0alpha1.Job{
Spec: v0alpha1.JobSpec{
Action: v0alpha1.JobActionPush,
Push: &v0alpha1.ExportJobOptions{},
},
}
mockRepo := repository.NewMockRepository(t)
mockRepo.On("Config").Return(&v0alpha1.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "test-namespace",
},
Spec: v0alpha1.RepositorySpec{
Type: v0alpha1.GitRepositoryType,
Git: &v0alpha1.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
},
Workflows: []v0alpha1.Workflow{v0alpha1.WriteWorkflow},
},
})
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(errors.New("export failed"))
mockCloneFn := NewMockWrapWithCloneFn(t)
mockCloneFn.On("Execute", mock.Anything, mockRepo, mock.Anything, mock.Anything, mock.Anything).Return(func(ctx context.Context, repo repository.Repository, cloneOpts repository.CloneOptions, pushOpts repository.PushOptions, fn func(repository.Repository, bool) error) error {
return fn(repo, true)
})
r := NewExportWorker(mockClients, mockRepoResources, mockExportFn.Execute, mockCloneFn.Execute)
err := r.Process(context.Background(), mockRepo, job, mockProgress)
require.EqualError(t, err, "export failed")
}
@@ -414,6 +414,28 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
r.Spec.GitHub.URL = strings.TrimSuffix(r.Spec.GitHub.URL, "/")
}
}
if r.Spec.Type == provisioning.GitRepositoryType {
if r.Spec.Git == nil {
return fmt.Errorf("git configuration is required")
}
if r.Spec.GitHub != nil {
return fmt.Errorf("git and github cannot be used together")
}
if r.Spec.Local != nil {
return fmt.Errorf("git and local cannot be used together")
}
// Trim trailing slash and ensure .git is present
if len(r.Spec.Git.URL) > 5 {
r.Spec.Git.URL = strings.TrimSuffix(r.Spec.Git.URL, "/")
if !strings.HasSuffix(r.Spec.Git.URL, ".git") {
r.Spec.Git.URL = r.Spec.Git.URL + ".git"
}
}
}
if r.Spec.Workflows == nil {
r.Spec.Workflows = []provisioning.Workflow{}
@@ -423,6 +445,10 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
return fmt.Errorf("failed to encrypt secrets: %w", err)
}
if err := b.encryptGitToken(ctx, r); err != nil {
return fmt.Errorf("failed to encrypt secrets: %w", err)
}
// Mutate the repository with any extra mutators
for _, extra := range b.extras {
if err := extra.Mutate(ctx, r); err != nil {
@@ -448,6 +474,21 @@ func (b *APIBuilder) encryptGithubToken(ctx context.Context, repo *provisioning.
return nil
}
// TODO: move this to a more appropriate place
func (b *APIBuilder) encryptGitToken(ctx context.Context, repo *provisioning.Repository) error {
var err error
if repo.Spec.Git != nil &&
repo.Spec.Git.Token != "" {
repo.Spec.Git.EncryptedToken, err = b.secrets.Encrypt(ctx, []byte(repo.Spec.Git.Token))
if err != nil {
return err
}
repo.Spec.Git.Token = ""
}
return nil
}
// TODO: move logic to a more appropriate place. Probably controller/validation.go
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
obj := a.GetObject()
@@ -1126,6 +1167,14 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
switch r.Spec.Type {
case provisioning.LocalRepositoryType:
return repository.NewLocal(r, b.localFileResolver), nil
case provisioning.GitRepositoryType:
return nanogit.NewGitRepository(ctx, b.secrets, r, nanogit.RepositoryConfig{
URL: r.Spec.Git.URL,
Branch: r.Spec.Git.Branch,
Path: r.Spec.Git.Path,
Token: r.Spec.Git.Token,
EncryptedToken: r.Spec.Git.EncryptedToken,
})
case provisioning.GitHubRepositoryType:
cloneFn := func(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) {
return gogit.Clone(ctx, b.clonedir, r, opts, b.secrets)
@@ -76,11 +76,16 @@ func ValidateRepository(repo Repository) field.ErrorList {
cfg.Spec.GitHub, "Github config only valid when type is github"))
}
if cfg.Spec.Type != provisioning.GitRepositoryType && cfg.Spec.Git != nil {
list = append(list, field.Invalid(field.NewPath("spec", "git"),
cfg.Spec.Git, "Git config only valid when type is git"))
}
for _, w := range cfg.Spec.Workflows {
switch w {
case provisioning.WriteWorkflow: // valid; no fall thru
case provisioning.BranchWorkflow:
if cfg.Spec.Type != provisioning.GitHubRepositoryType {
if !cfg.Spec.Type.IsGit() {
list = append(list, field.Invalid(field.NewPath("spec", "workflow"), w, "branch is only supported on git repositories"))
}
default:
@@ -151,6 +151,25 @@ func TestValidateRepository(t *testing.T) {
require.Contains(t, errors.ToAggregate().Error(), "spec.github: Invalid value")
},
},
{
name: "mismatched git config",
repository: func() *MockRepository {
m := NewMockRepository(t)
m.On("Config").Return(&provisioning.Repository{
Spec: provisioning.RepositorySpec{
Title: "Test Repo",
Type: provisioning.LocalRepositoryType,
Git: &provisioning.GitRepositoryConfig{},
},
})
m.On("Validate").Return(field.ErrorList{})
return m
}(),
expectedErrs: 1,
validateError: func(t *testing.T, errors field.ErrorList) {
require.Contains(t, errors.ToAggregate().Error(), "spec.git: Invalid value")
},
},
{
name: "multiple validation errors",
repository: func() *MockRepository {
@@ -17,7 +17,7 @@ func IsWriteAllowed(repo *provisioning.Repository, ref string) error {
case provisioning.WriteWorkflow:
supportsWrite = true
case provisioning.BranchWorkflow:
supportsBranch = repo.Spec.Type == provisioning.GitHubRepositoryType
supportsBranch = repo.Spec.Type.IsGit()
}
}
@@ -26,6 +26,11 @@ func IsWriteAllowed(repo *provisioning.Repository, ref string) error {
ref = ""
}
// Ref may be the configured branch for git repositories
if ref != "" && repo.Spec.Git != nil && repo.Spec.Git.Branch == ref {
ref = ""
}
switch {
case ref == "" && !supportsWrite:
return apierrors.NewBadRequest("this repository does not support the write workflow")
@@ -135,6 +135,191 @@ func TestIsWriteAllowed(t *testing.T) {
expectedErr: "this repository does not support the branch workflow",
statusCode: http.StatusBadRequest,
},
{
name: "write workflow allowed on git repository",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{provisioning.WriteWorkflow},
Git: &provisioning.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
},
},
},
ref: "",
wantErr: false,
},
{
name: "write allowed for configured branch of git repository",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{provisioning.WriteWorkflow},
Git: &provisioning.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "develop",
},
},
},
ref: "develop",
wantErr: false,
},
{
name: "write not allowed for configured branch of git repository",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{provisioning.BranchWorkflow},
Git: &provisioning.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
},
},
},
ref: "main",
wantErr: true,
expectedErr: "this repository does not support the write workflow",
statusCode: http.StatusBadRequest,
},
{
name: "write workflow not allowed for git repository",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{provisioning.BranchWorkflow},
Git: &provisioning.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
},
},
},
ref: "",
wantErr: true,
expectedErr: "this repository does not support the write workflow",
statusCode: http.StatusBadRequest,
},
{
name: "branch workflow allowed on git repository",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{provisioning.BranchWorkflow},
Git: &provisioning.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
},
},
},
ref: "feature-branch",
wantErr: false,
},
{
name: "branch workflow not allowed on git repository",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{provisioning.WriteWorkflow},
Git: &provisioning.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
},
},
},
ref: "feature-branch",
wantErr: true,
expectedErr: "this repository does not support the branch workflow",
statusCode: http.StatusBadRequest,
},
{
name: "both workflows allowed on git repository - write workflow",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{provisioning.WriteWorkflow, provisioning.BranchWorkflow},
Git: &provisioning.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
},
},
},
ref: "",
wantErr: false,
},
{
name: "both workflows allowed on git repository - branch workflow",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{provisioning.WriteWorkflow, provisioning.BranchWorkflow},
Git: &provisioning.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
},
},
},
ref: "feature-branch",
wantErr: false,
},
{
name: "read only git repository",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{},
Git: &provisioning.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "main",
},
},
},
ref: "",
wantErr: true,
expectedErr: "this repository is read only",
statusCode: http.StatusBadRequest,
},
{
name: "git repository with empty branch config - write workflow",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{provisioning.WriteWorkflow},
Git: &provisioning.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "",
},
},
},
ref: "",
wantErr: false,
},
{
name: "git repository with empty branch config - branch workflow",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{provisioning.BranchWorkflow},
Git: &provisioning.GitRepositoryConfig{
URL: "https://git.example.com/repo.git",
Branch: "",
},
},
},
ref: "custom-branch",
wantErr: false,
},
{
name: "git repository without git config",
repository: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Workflows: []provisioning.Workflow{provisioning.WriteWorkflow},
Git: nil,
},
},
ref: "",
wantErr: false,
},
}
for _, tt := range tests {