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
@@ -41,6 +41,8 @@ var RepositoryResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
target = m.Spec.Local.Path
case GitHubRepositoryType:
target = m.Spec.GitHub.URL
case GitRepositoryType:
target = m.Spec.Git.URL
}
return []interface{}{
+29 -2
View File
@@ -59,6 +59,24 @@ type GitHubRepositoryConfig struct {
Path string `json:"path,omitempty"`
}
type GitRepositoryConfig struct {
// The repository URL (e.g. `https://github.com/example/test.git`).
URL string `json:"url,omitempty"`
// The branch to use in the repository.
Branch string `json:"branch"`
// Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.
Token string `json:"token,omitempty"`
// Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.
// +listType=atomic
EncryptedToken []byte `json:"encryptedToken,omitempty"`
// Path is the subdirectory for the Grafana data. If specified, Grafana will ignore anything that is outside this directory in the repository.
// This is usually something like `grafana/`. Trailing and leading slash are not required. They are always added when needed.
// The path is relative to the root of the repository, regardless of the leading slash.
//
// When specifying something like `grafana-`, we will not look for `grafana-*`; we will only look for files under the directory `/grafana-/`. That means `/grafana-example.json` would not be found.
Path string `json:"path,omitempty"`
}
// RepositoryType defines the types of Repository
// +enum
type RepositoryType string
@@ -67,8 +85,14 @@ type RepositoryType string
const (
LocalRepositoryType RepositoryType = "local"
GitHubRepositoryType RepositoryType = "github"
GitRepositoryType RepositoryType = "git"
)
// IsGit returns true if the repository type is git or github
func (r RepositoryType) IsGit() bool {
return r == GitRepositoryType || r == GitHubRepositoryType
}
type RepositorySpec struct {
// The repository display name (shown in the UI)
Title string `json:"title"`
@@ -92,9 +116,12 @@ type RepositorySpec struct {
Local *LocalRepositoryConfig `json:"local,omitempty"`
// The repository on GitHub.
// Mutually exclusive with local | github.
// TODO: github or just 'git'??
// Mutually exclusive with local | github | git.
GitHub *GitHubRepositoryConfig `json:"github,omitempty"`
// The repository on Git.
// Mutually exclusive with local | github | git.
Git *GitRepositoryConfig `json:"git,omitempty"`
}
// SyncTargetType defines where we want all values to resolve
@@ -0,0 +1,40 @@
package v0alpha1_test
import (
"testing"
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
func TestRepositoryType_IsGit(t *testing.T) {
tests := []struct {
name string
repoType v0alpha1.RepositoryType
want bool
}{
{
name: "git",
repoType: v0alpha1.GitRepositoryType,
want: true,
},
{
name: "github",
repoType: v0alpha1.GitHubRepositoryType,
want: true,
},
{
name: "local",
repoType: v0alpha1.LocalRepositoryType,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.repoType.IsGit()
if got != tt.want {
t.Errorf("IsGit() = %v, want %v", got, tt.want)
}
})
}
}
@@ -127,6 +127,27 @@ func (in *GitHubRepositoryConfig) DeepCopy() *GitHubRepositoryConfig {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *GitRepositoryConfig) DeepCopyInto(out *GitRepositoryConfig) {
*out = *in
if in.EncryptedToken != nil {
in, out := &in.EncryptedToken, &out.EncryptedToken
*out = make([]byte, len(*in))
copy(*out, *in)
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitRepositoryConfig.
func (in *GitRepositoryConfig) DeepCopy() *GitRepositoryConfig {
if in == nil {
return nil
}
out := new(GitRepositoryConfig)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HealthStatus) DeepCopyInto(out *HealthStatus) {
*out = *in
@@ -501,6 +522,11 @@ func (in *RepositorySpec) DeepCopyInto(out *RepositorySpec) {
*out = new(GitHubRepositoryConfig)
(*in).DeepCopyInto(*out)
}
if in.Git != nil {
in, out := &in.Git, &out.Git
*out = new(GitRepositoryConfig)
(*in).DeepCopyInto(*out)
}
return
}
@@ -20,6 +20,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileItem": schema_pkg_apis_provisioning_v0alpha1_FileItem(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileList": schema_pkg_apis_provisioning_v0alpha1_FileList(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitRepositoryConfig(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.HealthStatus": schema_pkg_apis_provisioning_v0alpha1_HealthStatus(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.HistoryItem": schema_pkg_apis_provisioning_v0alpha1_HistoryItem(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.HistoryList": schema_pkg_apis_provisioning_v0alpha1_HistoryList(ref),
@@ -313,6 +314,60 @@ func schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref common.Ref
}
}
func schema_pkg_apis_provisioning_v0alpha1_GitRepositoryConfig(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"url": {
SchemaProps: spec.SchemaProps{
Description: "The repository URL (e.g. `https://github.com/example/test.git`).",
Type: []string{"string"},
Format: "",
},
},
"branch": {
SchemaProps: spec.SchemaProps{
Description: "The branch to use in the repository.",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"token": {
SchemaProps: spec.SchemaProps{
Description: "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.",
Type: []string{"string"},
Format: "",
},
},
"encryptedToken": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "atomic",
},
},
SchemaProps: spec.SchemaProps{
Description: "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.",
Type: []string{"string"},
Format: "byte",
},
},
"path": {
SchemaProps: spec.SchemaProps{
Description: "Path is the subdirectory for the Grafana data. If specified, Grafana will ignore anything that is outside this directory in the repository. This is usually something like `grafana/`. Trailing and leading slash are not required. They are always added when needed. The path is relative to the root of the repository, regardless of the leading slash.\n\nWhen specifying something like `grafana-`, we will not look for `grafana-*`; we will only look for files under the directory `/grafana-/`. That means `/grafana-example.json` would not be found.",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"branch"},
},
},
}
}
func schema_pkg_apis_provisioning_v0alpha1_HealthStatus(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -1041,11 +1096,11 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref common.ReferenceCa
},
"type": {
SchemaProps: spec.SchemaProps{
Description: "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`",
Description: "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"git\"`\n - `\"github\"`\n - `\"local\"`",
Default: "",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"github", "local"},
Enum: []interface{}{"git", "github", "local"},
},
},
"local": {
@@ -1056,16 +1111,22 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref common.ReferenceCa
},
"github": {
SchemaProps: spec.SchemaProps{
Description: "The repository on GitHub. Mutually exclusive with local | github.",
Description: "The repository on GitHub. Mutually exclusive with local | github | git.",
Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig"),
},
},
"git": {
SchemaProps: spec.SchemaProps{
Description: "The repository on Git. Mutually exclusive with local | github | git.",
Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitRepositoryConfig"),
},
},
},
Required: []string{"title", "workflows", "sync", "type"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncOptions"},
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncOptions"},
}
}
@@ -1156,11 +1217,11 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryView(ref common.ReferenceCa
},
"type": {
SchemaProps: spec.SchemaProps{
Description: "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`",
Description: "The repository type\n\nPossible enum values:\n - `\"git\"`\n - `\"github\"`\n - `\"local\"`",
Default: "",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"github", "local"},
Enum: []interface{}{"git", "github", "local"},
},
},
"target": {
@@ -1474,11 +1535,11 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceRepositoryInfo(ref common.Ref
Properties: map[string]spec.Schema{
"type": {
SchemaProps: spec.SchemaProps{
Description: "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`",
Description: "The repository type\n\nPossible enum values:\n - `\"git\"`\n - `\"github\"`\n - `\"local\"`",
Default: "",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"github", "local"},
Enum: []interface{}{"git", "github", "local"},
},
},
"title": {
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by applyconfiguration-gen. DO NOT EDIT.
package v0alpha1
// GitRepositoryConfigApplyConfiguration represents a declarative configuration of the GitRepositoryConfig type for use
// with apply.
type GitRepositoryConfigApplyConfiguration struct {
URL *string `json:"url,omitempty"`
Branch *string `json:"branch,omitempty"`
Token *string `json:"token,omitempty"`
EncryptedToken []byte `json:"encryptedToken,omitempty"`
Path *string `json:"path,omitempty"`
}
// GitRepositoryConfigApplyConfiguration constructs a declarative configuration of the GitRepositoryConfig type for use with
// apply.
func GitRepositoryConfig() *GitRepositoryConfigApplyConfiguration {
return &GitRepositoryConfigApplyConfiguration{}
}
// WithURL sets the URL field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the URL field is set to the value of the last call.
func (b *GitRepositoryConfigApplyConfiguration) WithURL(value string) *GitRepositoryConfigApplyConfiguration {
b.URL = &value
return b
}
// WithBranch sets the Branch field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Branch field is set to the value of the last call.
func (b *GitRepositoryConfigApplyConfiguration) WithBranch(value string) *GitRepositoryConfigApplyConfiguration {
b.Branch = &value
return b
}
// WithToken sets the Token field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Token field is set to the value of the last call.
func (b *GitRepositoryConfigApplyConfiguration) WithToken(value string) *GitRepositoryConfigApplyConfiguration {
b.Token = &value
return b
}
// WithEncryptedToken adds the given value to the EncryptedToken field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the EncryptedToken field.
func (b *GitRepositoryConfigApplyConfiguration) WithEncryptedToken(values ...byte) *GitRepositoryConfigApplyConfiguration {
for i := range values {
b.EncryptedToken = append(b.EncryptedToken, values[i])
}
return b
}
// WithPath sets the Path field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Path field is set to the value of the last call.
func (b *GitRepositoryConfigApplyConfiguration) WithPath(value string) *GitRepositoryConfigApplyConfiguration {
b.Path = &value
return b
}
@@ -18,6 +18,7 @@ type RepositorySpecApplyConfiguration struct {
Type *provisioningv0alpha1.RepositoryType `json:"type,omitempty"`
Local *LocalRepositoryConfigApplyConfiguration `json:"local,omitempty"`
GitHub *GitHubRepositoryConfigApplyConfiguration `json:"github,omitempty"`
Git *GitRepositoryConfigApplyConfiguration `json:"git,omitempty"`
}
// RepositorySpecApplyConfiguration constructs a declarative configuration of the RepositorySpec type for use with
@@ -83,3 +84,11 @@ func (b *RepositorySpecApplyConfiguration) WithGitHub(value *GitHubRepositoryCon
b.GitHub = value
return b
}
// WithGit sets the Git field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Git field is set to the value of the last call.
func (b *RepositorySpecApplyConfiguration) WithGit(value *GitRepositoryConfigApplyConfiguration) *RepositorySpecApplyConfiguration {
b.Git = value
return b
}
@@ -22,6 +22,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} {
// Group=provisioning.grafana.app, Version=v0alpha1
case v0alpha1.SchemeGroupVersion.WithKind("GitHubRepositoryConfig"):
return &provisioningv0alpha1.GitHubRepositoryConfigApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("GitRepositoryConfig"):
return &provisioningv0alpha1.GitRepositoryConfigApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("HealthStatus"):
return &provisioningv0alpha1.HealthStatusApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("LocalRepositoryConfig"):
@@ -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 {
@@ -2622,6 +2622,37 @@
}
}
},
"com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.GitRepositoryConfig": {
"type": "object",
"required": [
"branch"
],
"properties": {
"branch": {
"description": "The branch to use in the repository.",
"type": "string",
"default": ""
},
"encryptedToken": {
"description": "Token for accessing the repository, but encrypted. This is not possible to read back to a user decrypted.",
"type": "string",
"format": "byte",
"x-kubernetes-list-type": "atomic"
},
"path": {
"description": "Path is the subdirectory for the Grafana data. If specified, Grafana will ignore anything that is outside this directory in the repository. This is usually something like `grafana/`. Trailing and leading slash are not required. They are always added when needed. The path is relative to the root of the repository, regardless of the leading slash.\n\nWhen specifying something like `grafana-`, we will not look for `grafana-*`; we will only look for files under the directory `/grafana-/`. That means `/grafana-example.json` would not be found.",
"type": "string"
},
"token": {
"description": "Token for accessing the repository. If set, it will be encrypted into encryptedToken, then set to an empty string again.",
"type": "string"
},
"url": {
"description": "The repository URL (e.g. `https://github.com/example/test.git`).",
"type": "string"
}
}
},
"com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.HealthStatus": {
"type": "object",
"required": [
@@ -3127,8 +3158,16 @@
"description": "Repository description",
"type": "string"
},
"git": {
"description": "The repository on Git. Mutually exclusive with local | github | git.",
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.GitRepositoryConfig"
}
]
},
"github": {
"description": "The repository on GitHub. Mutually exclusive with local | github.",
"description": "The repository on GitHub. Mutually exclusive with local | github | git.",
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.GitHubRepositoryConfig"
@@ -3158,10 +3197,11 @@
"default": ""
},
"type": {
"description": "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`",
"description": "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"git\"`\n - `\"github\"`\n - `\"local\"`",
"type": "string",
"default": "",
"enum": [
"git",
"github",
"local"
]
@@ -3271,10 +3311,11 @@
"default": ""
},
"type": {
"description": "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`",
"description": "The repository type\n\nPossible enum values:\n - `\"git\"`\n - `\"github\"`\n - `\"local\"`",
"type": "string",
"default": "",
"enum": [
"git",
"github",
"local"
]
@@ -3523,10 +3564,11 @@
"default": ""
},
"type": {
"description": "The repository type\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`",
"description": "The repository type\n\nPossible enum values:\n - `\"git\"`\n - `\"github\"`\n - `\"local\"`",
"type": "string",
"default": "",
"enum": [
"git",
"github",
"local"
]