Provisioning: Use inline secrets for gitsync (#109908)

Co-authored-by: Clarity-89 <homes89@ukr.net>
Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com>
This commit is contained in:
Ryan McKinley
2025-08-22 18:38:28 +02:00
committed by GitHub
co-authored by Clarity-89 Roberto Jimenez Sanchez
parent 04f392d37b
commit ce65391067
69 changed files with 694 additions and 4255 deletions
@@ -31,7 +31,7 @@ type RepoGetter interface {
// Given a repository configuration, return it as a repository instance
// This will only error for un-recoverable system errors
// the repository instance may or may not be valid/healthy
AsRepository(ctx context.Context, cfg *provisioning.Repository) (repository.Repository, error)
RepositoryFromConfig(ctx context.Context, r *provisioning.Repository) (repository.Repository, error)
}
const loggerName = "provisioning-repository-controller"
@@ -223,7 +223,7 @@ func (rc *RepositoryController) handleDelete(ctx context.Context, obj *provision
// Process any finalizers
if len(obj.Finalizers) > 0 {
repo, err := rc.repoGetter.AsRepository(ctx, obj)
repo, err := rc.repoGetter.RepositoryFromConfig(ctx, obj)
if err != nil {
logger.Warn("unable to get repository for cleanup")
} else {
@@ -438,7 +438,7 @@ func (rc *RepositoryController) process(item *queueItem) error {
return nil
}
repo, err := rc.repoGetter.AsRepository(ctx, obj)
repo, err := rc.repoGetter.RepositoryFromConfig(ctx, obj)
if err != nil {
return fmt.Errorf("unable to create repository from configuration: %w", err)
}
+5 -4
View File
@@ -3,13 +3,14 @@ package provisioning
import (
"context"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/kube-openapi/pkg/spec3"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/kube-openapi/pkg/spec3"
)
type Extra interface {
@@ -17,7 +18,7 @@ type Extra interface {
UpdateStorage(storage map[string]rest.Storage) error
PostProcessOpenAPI(oas *spec3.OpenAPI) error
GetJobWorkers() []jobs.Worker
AsRepository(ctx context.Context, r *provisioning.Repository) (repository.Repository, error)
AsRepository(ctx context.Context, r *provisioning.Repository, secure repository.SecureValues) (repository.Repository, error)
RepositoryTypes() []provisioning.RepositoryType
Mutators() []controller.Mutator
}
+62 -63
View File
@@ -34,6 +34,7 @@ import (
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
informers "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions"
listers "github.com/grafana/grafana/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1"
commonMeta "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
apiutils "github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apiserver/readonly"
@@ -56,7 +57,6 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/usage"
"github.com/grafana/grafana/pkg/registry/apis/secret"
"github.com/grafana/grafana/pkg/services/apiserver"
@@ -98,20 +98,19 @@ type APIBuilder struct {
jobs.Queue
jobs.Store
}
jobHistoryConfig *JobHistoryConfig
jobHistoryLoki *jobs.LokiJobHistory
resourceLister resources.ResourceLister
repositoryLister listers.RepositoryLister
legacyMigrator legacy.LegacyMigrator
storageStatus dualwrite.Service
unified resource.ResourceClient
decryptSvc secret.DecryptService
repositorySecrets secrets.RepositorySecrets // << Will be removed when the decryptSvc usage is stable
client client.ProvisioningV0alpha1Interface
access authlib.AccessChecker
mutators []controller.Mutator
statusPatcher *controller.RepositoryStatusPatcher
healthChecker *controller.HealthChecker
jobHistoryConfig *JobHistoryConfig
jobHistoryLoki *jobs.LokiJobHistory
resourceLister resources.ResourceLister
repositoryLister listers.RepositoryLister
legacyMigrator legacy.LegacyMigrator
storageStatus dualwrite.Service
unified resource.ResourceClient
decrypter repository.Decrypter
client client.ProvisioningV0alpha1Interface
access authlib.AccessChecker
mutators []controller.Mutator
statusPatcher *controller.RepositoryStatusPatcher
healthChecker *controller.HealthChecker
// Extras provides additional functionality to the API.
extras []Extra
availableRepositoryTypes map[provisioning.RepositoryType]bool
@@ -130,7 +129,6 @@ func NewAPIBuilder(
storageStatus dualwrite.Service,
usageStats usagestats.Service,
decryptSvc secret.DecryptService,
repositorySecrets secrets.RepositorySecrets,
access authlib.AccessChecker,
tracer tracing.Tracer,
extraBuilders []ExtraBuilder,
@@ -141,8 +139,8 @@ func NewAPIBuilder(
resourceLister := resources.NewResourceLister(unified, unified, legacyMigrator, storageStatus)
mutators := []controller.Mutator{
git.Mutator(repositorySecrets),
github.Mutator(repositorySecrets),
git.Mutator(),
github.Mutator(),
}
b := &APIBuilder{
@@ -159,8 +157,7 @@ func NewAPIBuilder(
legacyMigrator: legacyMigrator,
storageStatus: storageStatus,
unified: unified,
decryptSvc: decryptSvc,
repositorySecrets: repositorySecrets,
decrypter: repository.DecryptService(decryptSvc),
access: access,
jobHistoryConfig: jobHistoryConfig,
availableRepositoryTypes: map[provisioning.RepositoryType]bool{
@@ -233,7 +230,6 @@ func RegisterAPIService(
storageStatus dualwrite.Service,
usageStats usagestats.Service,
decryptSvc secret.DecryptService,
repositorySecrets secrets.RepositorySecrets,
tracer tracing.Tracer,
extraBuilders []ExtraBuilder,
) (*APIBuilder, error) {
@@ -251,7 +247,6 @@ func RegisterAPIService(
legacyMigrator, storageStatus,
usageStats,
decryptSvc,
repositorySecrets,
access,
tracer,
extraBuilders,
@@ -562,7 +557,7 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
return nil
}
repo, err := b.asRepository(ctx, obj)
repo, err := b.asRepository(ctx, obj, a.GetOldObject())
if err != nil {
return err
}
@@ -571,7 +566,7 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
cfg := repo.Config()
if a.GetOperation() == admission.Update {
oldRepo, err := b.asRepository(ctx, a.GetOldObject())
oldRepo, err := b.asRepository(ctx, a.GetOldObject(), nil)
if err != nil {
return fmt.Errorf("get old repository for update: %w", err)
}
@@ -1263,7 +1258,7 @@ func (b *APIBuilder) GetRepository(ctx context.Context, name string) (repository
if err != nil {
return nil, err
}
return b.asRepository(ctx, obj)
return b.asRepository(ctx, obj, nil)
}
func (b *APIBuilder) GetHealthyRepository(ctx context.Context, name string) (repository.Repository, error) {
@@ -1283,7 +1278,7 @@ func (b *APIBuilder) GetHealthyRepository(ctx context.Context, name string) (rep
return repo, err
}
func (b *APIBuilder) asRepository(ctx context.Context, obj runtime.Object) (repository.Repository, error) {
func (b *APIBuilder) asRepository(ctx context.Context, obj runtime.Object, old runtime.Object) (repository.Repository, error) {
if obj == nil {
return nil, fmt.Errorf("missing repository object")
}
@@ -1291,13 +1286,30 @@ func (b *APIBuilder) asRepository(ctx context.Context, obj runtime.Object) (repo
if !ok {
return nil, fmt.Errorf("expected repository configuration")
}
return b.AsRepository(ctx, r)
// Copy previous values if they exist
if old != nil {
o, ok := old.(*provisioning.Repository)
if ok && !o.Secure.IsZero() {
if r.Secure.Token.IsZero() {
r.Secure.Token = o.Secure.Token
}
if r.Secure.WebhookSecret.IsZero() {
r.Secure.WebhookSecret = o.Secure.WebhookSecret
}
}
}
return b.RepositoryFromConfig(ctx, r)
}
func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) {
func (b *APIBuilder) RepositoryFromConfig(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) {
// Prepare a decrypter
secure := b.decrypter(r)
// Try first with any extra
for _, extra := range b.extras {
r, err := extra.AsRepository(ctx, r)
r, err := extra.AsRepository(ctx, r, secure)
if err != nil {
return nil, fmt.Errorf("convert repository for extra %T: %w", extra, err)
}
@@ -1307,6 +1319,15 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
}
}
var token commonMeta.RawSecureValue
if r.Secure.Token.IsZero() {
t, err := secure.Token(ctx)
if err != nil {
return nil, fmt.Errorf("unable to decrypt token: %w", err)
}
token = t
}
switch r.Spec.Type {
case provisioning.BitbucketRepositoryType:
return nil, errors.New("repository type bitbucket is not available")
@@ -1315,26 +1336,15 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
case provisioning.LocalRepositoryType:
return local.NewLocal(r, b.localFileResolver), nil
case provisioning.GitRepositoryType:
// Decrypt token if needed
token := r.Spec.Git.Token
if token == "" && len(r.Spec.Git.EncryptedToken) > 0 {
decrypted, err := b.repositorySecrets.Decrypt(ctx, r, string(r.Spec.Git.EncryptedToken))
if err != nil {
return nil, fmt.Errorf("decrypt git token: %w", err)
}
token = string(decrypted)
}
cfg := git.RepositoryConfig{
URL: r.Spec.Git.URL,
Branch: r.Spec.Git.Branch,
Path: r.Spec.Git.Path,
TokenUser: r.Spec.Git.TokenUser,
Token: token,
EncryptedToken: r.Spec.Git.EncryptedToken,
URL: r.Spec.Git.URL,
Branch: r.Spec.Git.Branch,
Path: r.Spec.Git.Path,
TokenUser: r.Spec.Git.TokenUser,
Token: token,
}
return git.NewGitRepository(ctx, r, cfg, b.repositorySecrets)
return git.NewGitRepository(ctx, r, cfg)
case provisioning.GitHubRepositoryType:
logger := logging.FromContext(ctx).With("url", r.Spec.GitHub.URL, "branch", r.Spec.GitHub.Branch, "path", r.Spec.GitHub.Path)
logger.Info("Instantiating Github repository")
@@ -1344,30 +1354,19 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
return nil, fmt.Errorf("github configuration is required for nano git")
}
// Decrypt GitHub token if needed
ghToken := ghCfg.Token
if ghToken == "" && len(ghCfg.EncryptedToken) > 0 {
decrypted, err := b.repositorySecrets.Decrypt(ctx, r, string(ghCfg.EncryptedToken))
if err != nil {
return nil, fmt.Errorf("decrypt github token: %w", err)
}
ghToken = string(decrypted)
}
gitCfg := git.RepositoryConfig{
URL: ghCfg.URL,
Branch: ghCfg.Branch,
Path: ghCfg.Path,
Token: ghToken,
EncryptedToken: ghCfg.EncryptedToken,
URL: ghCfg.URL,
Branch: ghCfg.Branch,
Path: ghCfg.Path,
Token: token,
}
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg, b.repositorySecrets)
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg)
if err != nil {
return nil, fmt.Errorf("error creating git repository: %w", err)
}
ghRepo, err := github.NewGitHub(ctx, r, gitRepo, b.ghFactory, ghToken, b.repositorySecrets)
ghRepo, err := github.NewGitHub(ctx, r, gitRepo, b.ghFactory, token)
if err != nil {
return nil, fmt.Errorf("error creating github repository: %w", err)
}
@@ -12,7 +12,6 @@ type GitRepository interface {
repository.Writer
repository.Reader
repository.StageableRepository
repository.Hooks
URL() string
Branch() string
}
@@ -9,10 +9,9 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
func Mutator(secrets secrets.RepositorySecrets) controller.Mutator {
func Mutator() controller.Mutator {
return func(ctx context.Context, obj runtime.Object) error {
repo, ok := obj.(*provisioning.Repository)
if !ok {
@@ -40,16 +39,6 @@ func Mutator(secrets secrets.RepositorySecrets) controller.Mutator {
}
}
if repo.Spec.Git.Token != "" {
secretName := repo.Name + gitTokenSecretSuffix
nameOrValue, err := secrets.Encrypt(ctx, repo, secretName, repo.Spec.Git.Token)
if err != nil {
return err
}
repo.Spec.Git.EncryptedToken = nameOrValue
repo.Spec.Git.Token = ""
}
return nil
}
}
@@ -2,98 +2,23 @@ package git
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
func TestMutator(t *testing.T) {
tests := []struct {
name string
obj runtime.Object
token string
setupMocks func(*secrets.MockRepositorySecrets)
expectedToken string
expectedEncryptedToken string
expectedError string
expectedURL string
name string
obj runtime.Object
token string
expectedError string
expectedURL string
}{
{
name: "successful token encryption",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Git: &provisioning.GitRepositoryConfig{
Token: "secret-token",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Git: &provisioning.GitRepositoryConfig{
Token: "secret-token",
},
},
},
"test-repo"+gitTokenSecretSuffix,
"secret-token",
).Return([]byte("encrypted-token"), nil)
},
expectedToken: "",
expectedEncryptedToken: "encrypted-token",
},
{
name: "encryption error",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Git: &provisioning.GitRepositoryConfig{
Token: "secret-token",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Git: &provisioning.GitRepositoryConfig{
Token: "secret-token",
},
},
},
"test-repo"+gitTokenSecretSuffix,
"secret-token",
).Return(nil, errors.New("encryption failed"))
},
expectedError: "encryption failed",
},
{
name: "no git spec",
obj: &provisioning.Repository{
@@ -106,11 +31,7 @@ func TestMutator(t *testing.T) {
Git: nil,
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "no git spec for git repository type",
obj: &provisioning.Repository{
@@ -123,9 +44,6 @@ func TestMutator(t *testing.T) {
Git: nil,
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
expectedError: "git configuration is required for git repository type",
},
{
@@ -137,21 +55,13 @@ func TestMutator(t *testing.T) {
},
Spec: provisioning.RepositorySpec{
Type: provisioning.GitRepositoryType,
Git: &provisioning.GitRepositoryConfig{
Token: "",
},
Git: &provisioning.GitRepositoryConfig{},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "non-repository object",
obj: &runtime.Unknown{},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "URL normalization - add .git suffix",
@@ -167,9 +77,6 @@ func TestMutator(t *testing.T) {
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
expectedURL: "https://github.com/grafana/grafana.git",
},
{
@@ -186,9 +93,6 @@ func TestMutator(t *testing.T) {
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
expectedURL: "https://github.com/grafana/grafana.git",
},
{
@@ -205,9 +109,6 @@ func TestMutator(t *testing.T) {
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
expectedURL: "https://github.com/grafana/grafana.git",
},
{
@@ -224,9 +125,6 @@ func TestMutator(t *testing.T) {
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
expectedURL: "https://github.com/grafana/grafana.git",
},
{
@@ -243,19 +141,13 @@ func TestMutator(t *testing.T) {
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
// No expectations
},
expectedURL: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMocks(mockSecrets)
mutator := Mutator(mockSecrets)
mutator := Mutator()
err := mutator(context.Background(), tt.obj)
if tt.expectedError != "" {
@@ -266,13 +158,6 @@ func TestMutator(t *testing.T) {
// Check that token was cleared and encrypted token was set
if repo, ok := tt.obj.(*provisioning.Repository); ok && repo.Spec.Git != nil {
if tt.expectedEncryptedToken != "" {
// Token should be cleared after encryption
assert.Empty(t, repo.Spec.Git.Token, "Token should be cleared after encryption")
// EncryptedToken should be set to the expected value
assert.Equal(t, tt.expectedEncryptedToken, string(repo.Spec.Git.EncryptedToken), "EncryptedToken should match expected value")
}
// Check URL normalization
if tt.expectedURL != "" {
assert.Equal(t, tt.expectedURL, repo.Spec.Git.URL, "URL should be normalized correctly")
@@ -17,9 +17,9 @@ import (
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/nanogit"
"github.com/grafana/nanogit/log"
"github.com/grafana/nanogit/options"
@@ -27,16 +27,12 @@ import (
"github.com/grafana/nanogit/protocol/hash"
)
//nolint:gosec // This is a constant for a secret suffix
const gitTokenSecretSuffix = "-git-token"
type RepositoryConfig struct {
URL string
Branch string
TokenUser string
Token string
EncryptedToken []byte
Path string
URL string
Branch string
TokenUser string
Token common.RawSecureValue
Path string
}
// Make sure all public functions of this struct call the (*gitRepository).logger function, to ensure the Git repo details are included.
@@ -44,23 +40,21 @@ type gitRepository struct {
config *provisioning.Repository
gitConfig RepositoryConfig
client nanogit.Client
secrets secrets.RepositorySecrets
}
func NewGitRepository(
ctx context.Context,
config *provisioning.Repository,
gitConfig RepositoryConfig,
secrets secrets.RepositorySecrets,
) (GitRepository, error) {
var opts []options.Option
if len(gitConfig.Token) > 0 {
if !gitConfig.Token.IsZero() {
tokenUser := gitConfig.TokenUser
if tokenUser == "" {
tokenUser = "git"
}
opts = append(opts, options.WithBasicAuth(tokenUser, gitConfig.Token))
opts = append(opts, options.WithBasicAuth(tokenUser, string(gitConfig.Token)))
}
client, err := nanogit.NewHTTPClient(gitConfig.URL, opts...)
@@ -72,7 +66,6 @@ func NewGitRepository(
config: config,
gitConfig: gitConfig,
client: client,
secrets: secrets,
}, nil
}
@@ -106,10 +99,10 @@ func (r *gitRepository) Validate() (list field.ErrorList) {
list = append(list, field.Invalid(field.NewPath("spec", t, "branch"), cfg.Branch, "invalid branch name"))
}
// If the repository has workflows, we require a token or encrypted token
// Readonly repositories may not need a token (if public)
if len(r.config.Spec.Workflows) > 0 {
if cfg.Token == "" && len(cfg.EncryptedToken) == 0 {
list = append(list, field.Required(field.NewPath("spec", t, "token"), "a git access token is required"))
if cfg.Token == "" && r.config.Secure.Token.IsZero() {
list = append(list, field.Required(field.NewPath("secure", "token"), "a git access token is required"))
}
}
@@ -166,7 +159,7 @@ func (r *gitRepository) Test(ctx context.Context) (*provisioning.TestResults, er
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", t, "token").String(),
Field: field.NewPath("secure", "token").String(),
Detail: detail,
}},
}, nil
@@ -832,23 +825,3 @@ func (r *gitRepository) logger(ctx context.Context, ref string) (context.Context
return ctx, logger
}
func (r *gitRepository) OnCreate(_ context.Context) ([]map[string]interface{}, error) {
return nil, nil
}
func (r *gitRepository) OnUpdate(_ context.Context) ([]map[string]interface{}, error) {
return nil, nil
}
func (r *gitRepository) OnDelete(ctx context.Context) error {
logger := logging.FromContext(ctx)
secretName := r.config.Name + gitTokenSecretSuffix
if err := r.secrets.Delete(ctx, r.config, secretName); err != nil {
return fmt.Errorf("delete git token secret: %w", err)
}
logger.Info("Deleted git token secret", "secretName", secretName)
return nil
}
@@ -14,7 +14,6 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/nanogit"
"github.com/grafana/nanogit/mocks"
"github.com/grafana/nanogit/protocol"
@@ -152,7 +151,7 @@ func TestGitRepository_Validate(t *testing.T) {
Token: "", // Empty token
},
want: field.ErrorList{
field.Required(field.NewPath("spec", "test_type", "token"), "a git access token is required"),
field.Required(field.NewPath("secure", "token"), "a git access token is required"),
},
},
{
@@ -291,10 +290,9 @@ func TestNewGit(t *testing.T) {
Path: "configs",
}
mockSecrets := secrets.NewMockRepositorySecrets(t)
// This should succeed in creating the client but won't be able to connect
// We just test that the basic structure is created correctly
gitRepo, err := NewGitRepository(ctx, config, gitConfig, mockSecrets)
gitRepo, err := NewGitRepository(ctx, config, gitConfig)
require.NoError(t, err)
require.NotNil(t, gitRepo)
require.Equal(t, "https://git.example.com/owner/repo.git", gitRepo.URL())
@@ -502,7 +500,7 @@ func TestGitRepository_Test(t *testing.T) {
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "test_type", "token").String(),
Field: field.NewPath("secure", "token").String(),
Detail: "failed check if authorized: auth error",
},
},
@@ -523,7 +521,7 @@ func TestGitRepository_Test(t *testing.T) {
Errors: []provisioning.ErrorDetails{
{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", "test_type", "token").String(),
Field: field.NewPath("secure", "token").String(),
Detail: "not authorized",
},
},
@@ -1862,8 +1860,7 @@ func TestNewGitRepository(t *testing.T) {
},
}
mockSecrets := secrets.NewMockRepositorySecrets(t)
gitRepo, err := NewGitRepository(ctx, config, tt.gitConfig, mockSecrets)
gitRepo, err := NewGitRepository(ctx, config, tt.gitConfig)
if tt.wantError {
require.Error(t, err)
@@ -2822,8 +2819,7 @@ func TestGitRepository_NewGitRepository_ClientError(t *testing.T) {
Path: "configs",
}
mockSecrets := secrets.NewMockRepositorySecrets(t)
gitRepo, err := NewGitRepository(ctx, config, gitConfig, mockSecrets)
gitRepo, err := NewGitRepository(ctx, config, gitConfig)
// We expect this to fail during client creation
require.Error(t, err)
@@ -3737,82 +3733,6 @@ func TestGitRepository_CompareFiles_FilesOutsideConfiguredPath_AllStatuses(t *te
}
}
func TestGitRepository_OnDelete(t *testing.T) {
tests := []struct {
name string
setupMock func(*secrets.MockRepositorySecrets)
config *provisioning.Repository
expectedError string
}{
{
name: "successful secret deletion",
setupMock: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Delete(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
"test-repo"+gitTokenSecretSuffix,
).Return(nil)
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
},
{
name: "secret deletion error",
setupMock: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Delete(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
"test-repo"+gitTokenSecretSuffix,
).Return(errors.New("failed to delete secret"))
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
expectedError: "delete git token secret: failed to delete secret",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMock(mockSecrets)
gitRepo := &gitRepository{
config: tt.config,
secrets: mockSecrets,
}
err := gitRepo.OnDelete(context.Background())
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
}
mockSecrets.AssertExpectations(t)
})
}
}
func TestGitRepository_Move(t *testing.T) {
tests := []struct {
name string
@@ -6,6 +6,8 @@ import (
"github.com/google/go-github/v70/github"
"golang.org/x/oauth2"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// Factory creates new GitHub clients.
@@ -20,16 +22,15 @@ func ProvideFactory() *Factory {
return &Factory{}
}
func (r *Factory) New(ctx context.Context, ghToken string) Client {
func (r *Factory) New(ctx context.Context, ghToken common.RawSecureValue) Client {
if r.Client != nil {
return NewClient(github.NewClient(r.Client))
}
tokenSrc := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: ghToken},
)
if len(ghToken) > 0 {
if !ghToken.IsZero() {
tokenSrc := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: string(ghToken)},
)
tokenClient := oauth2.NewClient(ctx, tokenSrc)
return NewClient(github.NewClient(tokenClient))
}
@@ -1,4 +1,4 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package github
@@ -503,168 +503,6 @@ func (_c *MockGithubRepository_Move_Call) RunAndReturn(run func(context.Context,
return _c
}
// OnCreate provides a mock function with given fields: ctx
func (_m *MockGithubRepository) OnCreate(ctx context.Context) ([]map[string]interface{}, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for OnCreate")
}
var r0 []map[string]interface{}
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]map[string]interface{}, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) []map[string]interface{}); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]map[string]interface{})
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGithubRepository_OnCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnCreate'
type MockGithubRepository_OnCreate_Call struct {
*mock.Call
}
// OnCreate is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockGithubRepository_Expecter) OnCreate(ctx interface{}) *MockGithubRepository_OnCreate_Call {
return &MockGithubRepository_OnCreate_Call{Call: _e.mock.On("OnCreate", ctx)}
}
func (_c *MockGithubRepository_OnCreate_Call) Run(run func(ctx context.Context)) *MockGithubRepository_OnCreate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockGithubRepository_OnCreate_Call) Return(_a0 []map[string]interface{}, _a1 error) *MockGithubRepository_OnCreate_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGithubRepository_OnCreate_Call) RunAndReturn(run func(context.Context) ([]map[string]interface{}, error)) *MockGithubRepository_OnCreate_Call {
_c.Call.Return(run)
return _c
}
// OnDelete provides a mock function with given fields: ctx
func (_m *MockGithubRepository) OnDelete(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for OnDelete")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockGithubRepository_OnDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnDelete'
type MockGithubRepository_OnDelete_Call struct {
*mock.Call
}
// OnDelete is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockGithubRepository_Expecter) OnDelete(ctx interface{}) *MockGithubRepository_OnDelete_Call {
return &MockGithubRepository_OnDelete_Call{Call: _e.mock.On("OnDelete", ctx)}
}
func (_c *MockGithubRepository_OnDelete_Call) Run(run func(ctx context.Context)) *MockGithubRepository_OnDelete_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockGithubRepository_OnDelete_Call) Return(_a0 error) *MockGithubRepository_OnDelete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockGithubRepository_OnDelete_Call) RunAndReturn(run func(context.Context) error) *MockGithubRepository_OnDelete_Call {
_c.Call.Return(run)
return _c
}
// OnUpdate provides a mock function with given fields: ctx
func (_m *MockGithubRepository) OnUpdate(ctx context.Context) ([]map[string]interface{}, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for OnUpdate")
}
var r0 []map[string]interface{}
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]map[string]interface{}, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) []map[string]interface{}); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]map[string]interface{})
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockGithubRepository_OnUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnUpdate'
type MockGithubRepository_OnUpdate_Call struct {
*mock.Call
}
// OnUpdate is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockGithubRepository_Expecter) OnUpdate(ctx interface{}) *MockGithubRepository_OnUpdate_Call {
return &MockGithubRepository_OnUpdate_Call{Call: _e.mock.On("OnUpdate", ctx)}
}
func (_c *MockGithubRepository_OnUpdate_Call) Run(run func(ctx context.Context)) *MockGithubRepository_OnUpdate_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockGithubRepository_OnUpdate_Call) Return(_a0 []map[string]interface{}, _a1 error) *MockGithubRepository_OnUpdate_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockGithubRepository_OnUpdate_Call) RunAndReturn(run func(context.Context) ([]map[string]interface{}, error)) *MockGithubRepository_OnUpdate_Call {
_c.Call.Return(run)
return _c
}
// Owner provides a mock function with no fields
func (_m *MockGithubRepository) Owner() string {
ret := _m.Called()
@@ -1,4 +1,4 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package github
@@ -1,4 +1,4 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package github
@@ -8,10 +8,9 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
func Mutator(secrets secrets.RepositorySecrets) controller.Mutator {
func Mutator() controller.Mutator {
return func(ctx context.Context, obj runtime.Object) error {
repo, ok := obj.(*provisioning.Repository)
if !ok {
@@ -31,16 +30,6 @@ func Mutator(secrets secrets.RepositorySecrets) controller.Mutator {
repo.Spec.GitHub.URL = url
}
if repo.Spec.GitHub.Token != "" {
secretName := repo.Name + githubTokenSecretSuffix
nameOrValue, err := secrets.Encrypt(ctx, repo, secretName, repo.Spec.GitHub.Token)
if err != nil {
return err
}
repo.Spec.GitHub.EncryptedToken = nameOrValue
repo.Spec.GitHub.Token = ""
}
return nil
}
}
@@ -2,25 +2,21 @@ package github
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
)
func TestMutator(t *testing.T) {
tests := []struct {
name string
obj runtime.Object
token string
setupMocks func(*secrets.MockRepositorySecrets)
expectedToken string
expectedEncryptedToken string
expectedError string
name string
obj runtime.Object
token string
expectedError string
}{
{
name: "trims trailing .git and slash from GitHub URL",
@@ -35,9 +31,6 @@ func TestMutator(t *testing.T) {
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {},
expectedToken: "",
expectedEncryptedToken: "",
},
{
name: "trims only trailing slash from GitHub URL",
@@ -52,9 +45,6 @@ func TestMutator(t *testing.T) {
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {},
expectedToken: "",
expectedEncryptedToken: "",
},
{
name: "trims only trailing .git from GitHub URL",
@@ -69,9 +59,6 @@ func TestMutator(t *testing.T) {
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {},
expectedToken: "",
expectedEncryptedToken: "",
},
{
name: "does not trim if no .git or slash",
@@ -86,76 +73,6 @@ func TestMutator(t *testing.T) {
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {},
expectedToken: "",
expectedEncryptedToken: "",
},
{
name: "successful token encryption",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Token: "secret-token",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Token: "secret-token",
},
},
},
"test-repo"+githubTokenSecretSuffix,
"secret-token",
).Return([]byte("encrypted-token"), nil)
},
expectedToken: "",
expectedEncryptedToken: "encrypted-token",
},
{
name: "encryption error",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Token: "secret-token",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Token: "secret-token",
},
},
},
"test-repo"+githubTokenSecretSuffix,
"secret-token",
).Return(nil, errors.New("encryption failed"))
},
expectedError: "encryption failed",
},
{
name: "no github spec",
@@ -168,9 +85,6 @@ func TestMutator(t *testing.T) {
GitHub: nil,
},
},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "empty token",
@@ -180,30 +94,19 @@ func TestMutator(t *testing.T) {
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Token: "",
},
GitHub: &provisioning.GitHubRepositoryConfig{},
},
},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "non-repository object",
obj: &runtime.Unknown{},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMocks(mockSecrets)
mutator := Mutator(mockSecrets)
mutator := Mutator()
err := mutator(context.Background(), tt.obj)
if tt.expectedError != "" {
@@ -211,16 +114,6 @@ func TestMutator(t *testing.T) {
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
// Check that token was cleared and encrypted token was set
if repo, ok := tt.obj.(*provisioning.Repository); ok && repo.Spec.GitHub != nil {
if tt.expectedEncryptedToken != "" {
// Token should be cleared after encryption
assert.Empty(t, repo.Spec.GitHub.Token, "Token should be cleared after encryption")
// EncryptedToken should be set to the expected value
assert.Equal(t, tt.expectedEncryptedToken, string(repo.Spec.GitHub.EncryptedToken), "EncryptedToken should match expected value")
}
}
}
})
}
@@ -7,25 +7,20 @@ import (
"net/url"
"strings"
"github.com/grafana/grafana-app-sdk/logging"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
//nolint:gosec // This is a constant for a secret suffix
const githubTokenSecretSuffix = "-github-token"
// Make sure all public functions of this struct call the (*githubRepository).logger function, to ensure the GH repo details are included.
type githubRepository struct {
git.GitRepository
config *provisioning.Repository
gh Client // assumes github.com base URL
secrets secrets.RepositorySecrets
config *provisioning.Repository
gh Client // assumes github.com base URL
owner string
repo string
@@ -42,7 +37,6 @@ type GithubRepository interface {
repository.Reader
repository.RepositoryWithURLs
repository.StageableRepository
repository.Hooks
Owner() string
Repo() string
Client() Client
@@ -53,8 +47,7 @@ func NewGitHub(
config *provisioning.Repository,
gitRepo git.GitRepository,
factory *Factory,
token string,
secrets secrets.RepositorySecrets,
token common.RawSecureValue,
) (GithubRepository, error) {
owner, repo, err := ParseOwnerRepoGithub(config.Spec.GitHub.URL)
if err != nil {
@@ -67,7 +60,6 @@ func NewGitHub(
gh: factory.New(ctx, token), // TODO, baseURL from config
owner: owner,
repo: repo,
secrets: secrets,
}, nil
}
@@ -242,23 +234,3 @@ func (r *githubRepository) RefURLs(ctx context.Context, ref string) (*provisioni
return urls, nil
}
func (r *githubRepository) OnCreate(_ context.Context) ([]map[string]interface{}, error) {
return nil, nil
}
func (r *githubRepository) OnUpdate(_ context.Context) ([]map[string]interface{}, error) {
return nil, nil
}
func (r *githubRepository) OnDelete(ctx context.Context) error {
logger := logging.FromContext(ctx)
secretName := r.config.Name + githubTokenSecretSuffix
if err := r.secrets.Delete(ctx, r.config, secretName); err != nil {
return fmt.Errorf("delete github token secret: %w", err)
}
logger.Info("Deleted github token secret", "secretName", secretName)
return nil
}
@@ -15,9 +15,9 @@ import (
field "k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
func TestNewGitHub(t *testing.T) {
@@ -81,16 +81,13 @@ func TestNewGitHub(t *testing.T) {
gitRepo := git.NewMockGitRepository(t)
mockSecrets := secrets.NewMockRepositorySecrets(t)
// Call the function under test
repo, err := NewGitHub(
context.Background(),
tt.config,
gitRepo,
factory,
tt.token,
mockSecrets,
common.RawSecureValue(tt.token),
)
// Check results
@@ -179,10 +176,14 @@ func TestGitHubRepositoryValidate(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/grafana/grafana",
Branch: "main",
Token: "valid-token",
Path: "dashboards",
},
},
Secure: provisioning.SecureValues{
Token: common.InlineSecureValue{
Name: "with-name",
},
},
},
mockSetup: func(m *git.MockGitRepository) {
m.On("Config").Return(&provisioning.Repository{
@@ -190,10 +191,14 @@ func TestGitHubRepositoryValidate(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/grafana/grafana",
Branch: "main",
Token: "valid-token",
Path: "dashboards",
},
},
Secure: provisioning.SecureValues{
Token: common.InlineSecureValue{
Name: "with-name",
},
},
})
m.On("Validate").Return(field.ErrorList{})
},
@@ -223,7 +228,11 @@ func TestGitHubRepositoryValidate(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "",
Branch: "main",
Token: "valid-token",
},
},
Secure: provisioning.SecureValues{
Token: common.InlineSecureValue{
Name: "with-name",
},
},
},
@@ -233,7 +242,11 @@ func TestGitHubRepositoryValidate(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "",
Branch: "main",
Token: "valid-token",
},
},
Secure: provisioning.SecureValues{
Token: common.InlineSecureValue{
Name: "with-name",
},
},
})
@@ -248,7 +261,11 @@ func TestGitHubRepositoryValidate(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "invalid-url",
Branch: "main",
Token: "valid-token",
},
},
Secure: provisioning.SecureValues{
Token: common.InlineSecureValue{
Name: "with-name",
},
},
},
@@ -258,7 +275,11 @@ func TestGitHubRepositoryValidate(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "invalid-url",
Branch: "main",
Token: "valid-token",
},
},
Secure: provisioning.SecureValues{
Token: common.InlineSecureValue{
Name: "with-name",
},
},
})
@@ -273,7 +294,11 @@ func TestGitHubRepositoryValidate(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://gitlab.com/grafana/grafana",
Branch: "main",
Token: "valid-token",
},
},
Secure: provisioning.SecureValues{
Token: common.InlineSecureValue{
Name: "with-name",
},
},
},
@@ -283,7 +308,11 @@ func TestGitHubRepositoryValidate(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://gitlab.com/grafana/grafana",
Branch: "main",
Token: "valid-token",
},
},
Secure: provisioning.SecureValues{
Token: common.InlineSecureValue{
Name: "with-name",
},
},
})
@@ -339,7 +368,11 @@ func TestGitHubRepositoryTest(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/grafana/grafana",
Branch: "main",
Token: "valid-token",
},
},
Secure: provisioning.SecureValues{
Token: common.InlineSecureValue{
Name: "with-name",
},
},
},
@@ -361,7 +394,11 @@ func TestGitHubRepositoryTest(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "invalid-url",
Branch: "main",
Token: "valid-token",
},
},
Secure: provisioning.SecureValues{
Token: common.InlineSecureValue{
Name: "with-name",
},
},
},
@@ -878,7 +915,11 @@ func TestGitHubRepositoryDelegation(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/grafana/grafana",
Branch: "main",
Token: "test-token",
},
},
Secure: provisioning.SecureValues{
Token: common.InlineSecureValue{
Name: "with-name",
},
},
}
@@ -1095,7 +1136,6 @@ func TestGitHubRepositoryAccessors(t *testing.T) {
GitHub: &provisioning.GitHubRepositoryConfig{
URL: "https://github.com/grafana/grafana",
Branch: "main",
Token: "test-token",
},
},
}
@@ -1137,82 +1177,6 @@ func TestGitHubRepositoryAccessors(t *testing.T) {
})
}
func TestGitHubRepository_OnDelete(t *testing.T) {
tests := []struct {
name string
setupMock func(*secrets.MockRepositorySecrets)
config *provisioning.Repository
expectedError string
}{
{
name: "successful secret deletion",
setupMock: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Delete(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
"test-repo"+githubTokenSecretSuffix,
).Return(nil)
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
},
{
name: "secret deletion error",
setupMock: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Delete(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
"test-repo"+githubTokenSecretSuffix,
).Return(errors.New("failed to delete secret"))
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
},
expectedError: "delete github token secret: failed to delete secret",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMock(mockSecrets)
githubRepo := &githubRepository{
config: tt.config,
secrets: mockSecrets,
}
err := githubRepo.OnDelete(context.Background())
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
}
mockSecrets.AssertExpectations(t)
})
}
}
func TestGithubRepository_Move(t *testing.T) {
tests := []struct {
name string
@@ -1262,7 +1226,6 @@ func TestGithubRepository_Move(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// Create mock git repository
mockGitRepo := git.NewMockGitRepository(t)
mockSecrets := &secrets.MockRepositorySecrets{}
// Setup mock expectations
tt.setupMock(mockGitRepo)
@@ -1285,7 +1248,6 @@ func TestGithubRepository_Move(t *testing.T) {
GitRepository: mockGitRepo,
owner: "example",
repo: "repo",
secrets: mockSecrets,
}
// Execute move operation
@@ -0,0 +1,59 @@
package repository
import (
"context"
"fmt"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
)
type Decrypter = func(r *provisioning.Repository) SecureValues
type SecureValues interface {
Token(ctx context.Context) (common.RawSecureValue, error)
WebhookSecret(ctx context.Context) (common.RawSecureValue, error)
}
type secureValues struct {
svc contracts.DecryptService
names provisioning.SecureValues
namespace string
}
func (s *secureValues) get(ctx context.Context, sv common.InlineSecureValue) (common.RawSecureValue, error) {
if !sv.Create.IsZero() {
return sv.Create, nil // If this was called before the value is actually saved
}
if sv.Name == "" {
return "", nil
}
results, err := s.svc.Decrypt(ctx, provisioning.GROUP, s.namespace, sv.Name)
if err != nil {
return "", fmt.Errorf("failed to call decrypt service: %w", err)
}
v, found := results[sv.Name]
if !found {
return "", fmt.Errorf("not found")
}
if v.Error() != nil {
return "", v.Error()
}
return common.RawSecureValue(*v.Value()), nil
}
func (s *secureValues) Token(ctx context.Context) (common.RawSecureValue, error) {
return s.get(ctx, s.names.Token)
}
func (s *secureValues) WebhookSecret(ctx context.Context) (common.RawSecureValue, error) {
return s.get(ctx, s.names.WebhookSecret)
}
func DecryptService(svc contracts.DecryptService) Decrypter {
return func(r *provisioning.Repository) SecureValues {
return &secureValues{svc: svc, names: r.Secure, namespace: r.Namespace}
}
}
@@ -0,0 +1,161 @@
package repository
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
)
func TestRepositorySecureValues(t *testing.T) {
type expectedDecryptedResult struct {
value string
error string
}
tests := []struct {
name string
config *provisioning.Repository
decrypt decryptFn
token expectedDecryptedResult
webhook expectedDecryptedResult
}{
{
name: "referenced by name",
config: &provisioning.Repository{
Secure: provisioning.SecureValues{
Token: v0alpha1.InlineSecureValue{
Name: "secret",
},
},
},
decrypt: func(t *testing.T, names ...string) (map[string]contracts.DecryptResult, error) {
require.Equal(t, []string{"secret"}, names)
val := secretv1beta1.NewExposedSecureValue(names[0])
return map[string]contracts.DecryptResult{
names[0]: contracts.NewDecryptResultValue(&val),
}, nil
},
token: expectedDecryptedResult{
value: "secret",
},
},
{
name: "when create exists, use it",
config: &provisioning.Repository{
Secure: provisioning.SecureValues{
Token: v0alpha1.InlineSecureValue{
Create: "secret",
},
},
},
decrypt: func(t *testing.T, names ...string) (map[string]contracts.DecryptResult, error) {
t.Fatal("decrypt should not be called when Create is set")
return nil, nil
},
token: expectedDecryptedResult{
value: "secret",
},
},
{
name: "avoid decrypt when no values are configured",
config: &provisioning.Repository{
Secure: provisioning.SecureValues{},
},
decrypt: func(t *testing.T, names ...string) (map[string]contracts.DecryptResult, error) {
t.Fatal("decrypt should not be called when no values are configured")
return nil, nil
},
},
{
name: "propagate error from service",
config: &provisioning.Repository{
Secure: provisioning.SecureValues{
WebhookSecret: v0alpha1.InlineSecureValue{
Name: "secret",
},
},
},
decrypt: func(t *testing.T, names ...string) (map[string]contracts.DecryptResult, error) {
require.Equal(t, []string{"secret"}, names)
return map[string]contracts.DecryptResult{
names[0]: contracts.NewDecryptResultErr(fmt.Errorf("error for name")),
}, nil
},
webhook: expectedDecryptedResult{
error: "error for name",
},
},
{
name: "not found",
config: &provisioning.Repository{
Secure: provisioning.SecureValues{
Token: v0alpha1.InlineSecureValue{
Name: "secret",
},
},
},
decrypt: func(t *testing.T, names ...string) (map[string]contracts.DecryptResult, error) {
return map[string]contracts.DecryptResult{}, nil
},
token: expectedDecryptedResult{
error: "not found", // it is not in the results above
},
},
{
name: "not found",
config: &provisioning.Repository{
Secure: provisioning.SecureValues{
WebhookSecret: v0alpha1.InlineSecureValue{
Name: "secret",
},
},
},
decrypt: func(t *testing.T, names ...string) (map[string]contracts.DecryptResult, error) {
return nil, fmt.Errorf("explode")
},
webhook: expectedDecryptedResult{
error: "failed to call decrypt service",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
decrypter := DecryptService(&dummyDecryptService{t: t, fn: tt.decrypt})
decrypted := decrypter(tt.config)
token, err := decrypted.Token(context.Background())
if tt.token.error != "" {
require.ErrorContains(t, err, tt.token.error)
} else {
require.NoError(t, err)
require.Equal(t, tt.token.value, string(token))
}
webhook, err := decrypted.WebhookSecret(context.Background())
if tt.webhook.error != "" {
require.ErrorContains(t, err, tt.webhook.error)
} else {
require.NoError(t, err)
require.Equal(t, tt.webhook.value, string(webhook))
}
})
}
}
type decryptFn = func(t *testing.T, names ...string) (map[string]contracts.DecryptResult, error)
type dummyDecryptService struct {
t *testing.T
fn decryptFn
}
func (d *dummyDecryptService) Decrypt(_ context.Context, _ string, _ string, names ...string) (map[string]contracts.DecryptResult, error) {
return d.fn(d.t, names...)
}
@@ -1,37 +0,0 @@
package secrets
import (
"context"
"github.com/grafana/grafana/pkg/services/secrets"
)
// A secrets encryption service. It only operates on values, no names or similar.
// It is likely we will need to change this when the multi-tenant service comes around.
//
// FIXME: this is a temporary service/package until we can make use of
// the new secrets service in app platform.
//
//go:generate mockery --name LegacyService --structname MockLegacyService --inpackage --filename legacy_secret_mock.go --with-expecter
type LegacyService interface {
Encrypt(ctx context.Context, data []byte) ([]byte, error)
Decrypt(ctx context.Context, data []byte) ([]byte, error)
}
var _ LegacyService = (*singleTenant)(nil)
type singleTenant struct {
inner secrets.Service
}
func NewSingleTenant(svc secrets.Service) LegacyService {
return &singleTenant{svc}
}
func (s *singleTenant) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
return s.inner.Encrypt(ctx, data, secrets.WithoutScope())
}
func (s *singleTenant) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
return s.inner.Decrypt(ctx, data)
}
@@ -1,154 +0,0 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package secrets
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockLegacyService is an autogenerated mock type for the LegacyService type
type MockLegacyService struct {
mock.Mock
}
type MockLegacyService_Expecter struct {
mock *mock.Mock
}
func (_m *MockLegacyService) EXPECT() *MockLegacyService_Expecter {
return &MockLegacyService_Expecter{mock: &_m.Mock}
}
// Decrypt provides a mock function with given fields: ctx, data
func (_m *MockLegacyService) Decrypt(ctx context.Context, data []byte) ([]byte, error) {
ret := _m.Called(ctx, data)
if len(ret) == 0 {
panic("no return value specified for Decrypt")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok {
return rf(ctx, data)
}
if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok {
r0 = rf(ctx, data)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
r1 = rf(ctx, data)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockLegacyService_Decrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Decrypt'
type MockLegacyService_Decrypt_Call struct {
*mock.Call
}
// Decrypt is a helper method to define mock.On call
// - ctx context.Context
// - data []byte
func (_e *MockLegacyService_Expecter) Decrypt(ctx interface{}, data interface{}) *MockLegacyService_Decrypt_Call {
return &MockLegacyService_Decrypt_Call{Call: _e.mock.On("Decrypt", ctx, data)}
}
func (_c *MockLegacyService_Decrypt_Call) Run(run func(ctx context.Context, data []byte)) *MockLegacyService_Decrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].([]byte))
})
return _c
}
func (_c *MockLegacyService_Decrypt_Call) Return(_a0 []byte, _a1 error) *MockLegacyService_Decrypt_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockLegacyService_Decrypt_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *MockLegacyService_Decrypt_Call {
_c.Call.Return(run)
return _c
}
// Encrypt provides a mock function with given fields: ctx, data
func (_m *MockLegacyService) Encrypt(ctx context.Context, data []byte) ([]byte, error) {
ret := _m.Called(ctx, data)
if len(ret) == 0 {
panic("no return value specified for Encrypt")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, []byte) ([]byte, error)); ok {
return rf(ctx, data)
}
if rf, ok := ret.Get(0).(func(context.Context, []byte) []byte); ok {
r0 = rf(ctx, data)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(context.Context, []byte) error); ok {
r1 = rf(ctx, data)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockLegacyService_Encrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encrypt'
type MockLegacyService_Encrypt_Call struct {
*mock.Call
}
// Encrypt is a helper method to define mock.On call
// - ctx context.Context
// - data []byte
func (_e *MockLegacyService_Expecter) Encrypt(ctx interface{}, data interface{}) *MockLegacyService_Encrypt_Call {
return &MockLegacyService_Encrypt_Call{Call: _e.mock.On("Encrypt", ctx, data)}
}
func (_c *MockLegacyService_Encrypt_Call) Run(run func(ctx context.Context, data []byte)) *MockLegacyService_Encrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].([]byte))
})
return _c
}
func (_c *MockLegacyService_Encrypt_Call) Return(_a0 []byte, _a1 error) *MockLegacyService_Encrypt_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockLegacyService_Encrypt_Call) RunAndReturn(run func(context.Context, []byte) ([]byte, error)) *MockLegacyService_Encrypt_Call {
_c.Call.Return(run)
return _c
}
// NewMockLegacyService creates a new instance of MockLegacyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockLegacyService(t interface {
mock.TestingT
Cleanup(func())
}) *MockLegacyService {
mock := &MockLegacyService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,112 +0,0 @@
package secrets
import (
"context"
"errors"
"strings"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/secret"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/services/featuremgmt"
legacysecrets "github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/setting"
)
func ProvideRepositorySecrets(
features featuremgmt.FeatureToggles,
legacySecretsSvc legacysecrets.Service,
secretsSvc contracts.SecureValueClient,
decryptSvc secret.DecryptService,
cfg *setting.Cfg,
) RepositorySecrets {
return NewRepositorySecrets(features, NewSecretsService(secretsSvc, decryptSvc, cfg.SecretsManagement.GrpcGrafanaServiceName), NewSingleTenant(legacySecretsSvc))
}
//go:generate mockery --name RepositorySecrets --structname MockRepositorySecrets --inpackage --filename repository_secrets_mock.go --with-expecter
type RepositorySecrets interface {
Encrypt(ctx context.Context, r *provisioning.Repository, name string, data string) (nameOrValue []byte, err error)
Decrypt(ctx context.Context, r *provisioning.Repository, nameOrValue string) (data []byte, err error)
Delete(ctx context.Context, r *provisioning.Repository, nameOrValue string) error
}
// repositorySecrets provides a unified interface for encrypting and decrypting repository secrets,
// supporting both the legacy and new secrets services. The active backend is determined by the
// FlagProvisioningSecretsService feature flag:
// - If enabled, operations use the new secrets service.
// - If disabled, operations use the legacy secrets service.
//
// This abstraction enables a seamless migration path between secret backends without breaking
// existing functionality. Once migration is complete and the legacy service is deprecated,
// this wrapper should be removed.
type repositorySecrets struct {
features featuremgmt.FeatureToggles
secretsSvc Service
legacySecrets LegacyService
}
func NewRepositorySecrets(features featuremgmt.FeatureToggles, secretsSvc Service, legacySecrets LegacyService) RepositorySecrets {
return &repositorySecrets{
features: features,
secretsSvc: secretsSvc,
legacySecrets: legacySecrets,
}
}
// Encrypt encrypts the data and returns the name or value of the encrypted data
// If the feature flag is disabled, it uses the legacy secrets service
// If the feature flag is enabled, it uses the secrets service
func (s *repositorySecrets) Encrypt(ctx context.Context, r *provisioning.Repository, name string, data string) (nameOrValue []byte, err error) {
logger := logging.FromContext(ctx).With("name", name, "namespace", r.GetNamespace())
if s.features.IsEnabled(ctx, featuremgmt.FlagProvisioningSecretsService) {
logger.Info("Encrypting secret with new secrets service")
encrypted, err := s.secretsSvc.Encrypt(ctx, r.GetNamespace(), name, data)
if err != nil {
return nil, err
}
return []byte(encrypted), err
}
logger.Info("Encrypting secret with legacy secrets service")
encrypted, err := s.legacySecrets.Encrypt(ctx, []byte(data))
if err != nil {
return nil, err
}
return encrypted, nil
}
// Decrypt retrieves and decrypts secret data for a repository, supporting migration between secret backends.
// The backend used for decryption is determined by a heuristic:
// - If the provided nameOrValue starts with the repository name, it is assumed to be a Kubernetes secret name
// and the new secrets service is used for decryption.
// - Otherwise, it is treated as a legacy secret value and the legacy secrets service is used.
//
// HACK: This approach relies on checking the prefix of nameOrValue to distinguish between secret backends.
// This is a temporary workaround to support both backends during migration and should be removed once
// migration is complete.
//
// This method ensures compatibility and minimizes disruption during the transition between secret backends.
func (s *repositorySecrets) Decrypt(ctx context.Context, r *provisioning.Repository, nameOrValue string) ([]byte, error) {
logger := logging.FromContext(ctx)
// HACK: this is a hack to identify if the name is a potential Kubernetes name for a secret.
if strings.HasPrefix(nameOrValue, r.GetName()) {
logger.Info("Decrypting secret with new secrets service")
return s.secretsSvc.Decrypt(ctx, r.GetNamespace(), nameOrValue)
} else {
logger.Info("Decrypting secret with legacy secrets service")
return s.legacySecrets.Decrypt(ctx, []byte(nameOrValue))
}
}
func (s *repositorySecrets) Delete(ctx context.Context, r *provisioning.Repository, nameOrValue string) error {
if s.features.IsEnabled(ctx, featuremgmt.FlagProvisioningSecretsService) {
err := s.secretsSvc.Delete(ctx, r.GetNamespace(), nameOrValue)
if err != nil && !errors.Is(err, contracts.ErrSecureValueNotFound) {
return err
}
}
return nil
}
@@ -1,206 +0,0 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
package secrets
import (
context "context"
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockRepositorySecrets is an autogenerated mock type for the RepositorySecrets type
type MockRepositorySecrets struct {
mock.Mock
}
type MockRepositorySecrets_Expecter struct {
mock *mock.Mock
}
func (_m *MockRepositorySecrets) EXPECT() *MockRepositorySecrets_Expecter {
return &MockRepositorySecrets_Expecter{mock: &_m.Mock}
}
// Decrypt provides a mock function with given fields: ctx, r, nameOrValue
func (_m *MockRepositorySecrets) Decrypt(ctx context.Context, r *v0alpha1.Repository, nameOrValue string) ([]byte, error) {
ret := _m.Called(ctx, r, nameOrValue)
if len(ret) == 0 {
panic("no return value specified for Decrypt")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository, string) ([]byte, error)); ok {
return rf(ctx, r, nameOrValue)
}
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository, string) []byte); ok {
r0 = rf(ctx, r, nameOrValue)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Repository, string) error); ok {
r1 = rf(ctx, r, nameOrValue)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockRepositorySecrets_Decrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Decrypt'
type MockRepositorySecrets_Decrypt_Call struct {
*mock.Call
}
// Decrypt is a helper method to define mock.On call
// - ctx context.Context
// - r *v0alpha1.Repository
// - nameOrValue string
func (_e *MockRepositorySecrets_Expecter) Decrypt(ctx interface{}, r interface{}, nameOrValue interface{}) *MockRepositorySecrets_Decrypt_Call {
return &MockRepositorySecrets_Decrypt_Call{Call: _e.mock.On("Decrypt", ctx, r, nameOrValue)}
}
func (_c *MockRepositorySecrets_Decrypt_Call) Run(run func(ctx context.Context, r *v0alpha1.Repository, nameOrValue string)) *MockRepositorySecrets_Decrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v0alpha1.Repository), args[2].(string))
})
return _c
}
func (_c *MockRepositorySecrets_Decrypt_Call) Return(data []byte, err error) *MockRepositorySecrets_Decrypt_Call {
_c.Call.Return(data, err)
return _c
}
func (_c *MockRepositorySecrets_Decrypt_Call) RunAndReturn(run func(context.Context, *v0alpha1.Repository, string) ([]byte, error)) *MockRepositorySecrets_Decrypt_Call {
_c.Call.Return(run)
return _c
}
// Delete provides a mock function with given fields: ctx, r, nameOrValue
func (_m *MockRepositorySecrets) Delete(ctx context.Context, r *v0alpha1.Repository, nameOrValue string) error {
ret := _m.Called(ctx, r, nameOrValue)
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository, string) error); ok {
r0 = rf(ctx, r, nameOrValue)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockRepositorySecrets_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type MockRepositorySecrets_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
// - ctx context.Context
// - r *v0alpha1.Repository
// - nameOrValue string
func (_e *MockRepositorySecrets_Expecter) Delete(ctx interface{}, r interface{}, nameOrValue interface{}) *MockRepositorySecrets_Delete_Call {
return &MockRepositorySecrets_Delete_Call{Call: _e.mock.On("Delete", ctx, r, nameOrValue)}
}
func (_c *MockRepositorySecrets_Delete_Call) Run(run func(ctx context.Context, r *v0alpha1.Repository, nameOrValue string)) *MockRepositorySecrets_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v0alpha1.Repository), args[2].(string))
})
return _c
}
func (_c *MockRepositorySecrets_Delete_Call) Return(_a0 error) *MockRepositorySecrets_Delete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockRepositorySecrets_Delete_Call) RunAndReturn(run func(context.Context, *v0alpha1.Repository, string) error) *MockRepositorySecrets_Delete_Call {
_c.Call.Return(run)
return _c
}
// Encrypt provides a mock function with given fields: ctx, r, name, data
func (_m *MockRepositorySecrets) Encrypt(ctx context.Context, r *v0alpha1.Repository, name string, data string) ([]byte, error) {
ret := _m.Called(ctx, r, name, data)
if len(ret) == 0 {
panic("no return value specified for Encrypt")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository, string, string) ([]byte, error)); ok {
return rf(ctx, r, name, data)
}
if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Repository, string, string) []byte); ok {
r0 = rf(ctx, r, name, data)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Repository, string, string) error); ok {
r1 = rf(ctx, r, name, data)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockRepositorySecrets_Encrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encrypt'
type MockRepositorySecrets_Encrypt_Call struct {
*mock.Call
}
// Encrypt is a helper method to define mock.On call
// - ctx context.Context
// - r *v0alpha1.Repository
// - name string
// - data string
func (_e *MockRepositorySecrets_Expecter) Encrypt(ctx interface{}, r interface{}, name interface{}, data interface{}) *MockRepositorySecrets_Encrypt_Call {
return &MockRepositorySecrets_Encrypt_Call{Call: _e.mock.On("Encrypt", ctx, r, name, data)}
}
func (_c *MockRepositorySecrets_Encrypt_Call) Run(run func(ctx context.Context, r *v0alpha1.Repository, name string, data string)) *MockRepositorySecrets_Encrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*v0alpha1.Repository), args[2].(string), args[3].(string))
})
return _c
}
func (_c *MockRepositorySecrets_Encrypt_Call) Return(nameOrValue []byte, err error) *MockRepositorySecrets_Encrypt_Call {
_c.Call.Return(nameOrValue, err)
return _c
}
func (_c *MockRepositorySecrets_Encrypt_Call) RunAndReturn(run func(context.Context, *v0alpha1.Repository, string, string) ([]byte, error)) *MockRepositorySecrets_Encrypt_Call {
_c.Call.Return(run)
return _c
}
// NewMockRepositorySecrets creates a new instance of MockRepositorySecrets. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockRepositorySecrets(t interface {
mock.TestingT
Cleanup(func())
}) *MockRepositorySecrets {
mock := &MockRepositorySecrets{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,322 +0,0 @@
package secrets
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type testSetup struct {
rs RepositorySecrets
mockFeatures *featuremgmt.MockFeatureToggles
mockSecrets *MockService
mockLegacy *MockLegacyService
repo *provisioning.Repository
ctx context.Context
}
func setupTest(t *testing.T, namespace string) *testSetup {
mockFeatures := featuremgmt.NewMockFeatureToggles(t)
mockSecrets := NewMockService(t)
mockLegacy := NewMockLegacyService(t)
rs := NewRepositorySecrets(mockFeatures, mockSecrets, mockLegacy)
repo := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: namespace,
},
}
return &testSetup{
rs: rs,
mockFeatures: mockFeatures,
mockSecrets: mockSecrets,
mockLegacy: mockLegacy,
repo: repo,
ctx: context.Background(),
}
}
func (s *testSetup) expectFeatureFlag(enabled bool) {
s.mockFeatures.EXPECT().IsEnabled(
mock.AnythingOfType("context.backgroundCtx"),
featuremgmt.FlagProvisioningSecretsService,
).Return(enabled)
}
func TestRepositorySecrets_Encrypt(t *testing.T) {
tests := []struct {
name string
namespace string
featureEnabled bool
setupMocks func(*testSetup)
expectedResult []byte
expectedError string
}{
{
name: "new service success",
namespace: "test-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Encrypt(s.ctx, "test-namespace", "test-secret", "secret-data").Return("encrypted-name", nil)
},
expectedResult: []byte("encrypted-name"),
},
{
name: "legacy service success",
namespace: "test-namespace",
featureEnabled: false,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(false)
s.mockLegacy.EXPECT().Encrypt(s.ctx, []byte("secret-data")).Return([]byte("encrypted-legacy-data"), nil)
},
expectedResult: []byte("encrypted-legacy-data"),
},
{
name: "new service error",
namespace: "test-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Encrypt(s.ctx, "test-namespace", "test-secret", "secret-data").Return("", errors.New("encryption failed"))
},
expectedError: "encryption failed",
},
{
name: "legacy service error",
namespace: "test-namespace",
featureEnabled: false,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(false)
s.mockLegacy.EXPECT().Encrypt(s.ctx, []byte("secret-data")).Return(nil, errors.New("legacy encryption failed"))
},
expectedError: "legacy encryption failed",
},
{
name: "empty namespace handling",
namespace: "",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Encrypt(s.ctx, "", "test-secret", "secret-data").Return("encrypted", nil)
},
expectedResult: []byte("encrypted"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setup := setupTest(t, tt.namespace)
tt.setupMocks(setup)
result, err := setup.rs.Encrypt(setup.ctx, setup.repo, "test-secret", "secret-data")
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, result)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedResult, result)
}
})
}
}
func TestRepositorySecrets_Decrypt(t *testing.T) {
tests := []struct {
name string
namespace string
nameOrValue string
setupMocks func(*testSetup)
expectedResult []byte
expectedError string
}{
{
name: "new service success - name starts with repo name",
namespace: "test-namespace",
nameOrValue: "test-repo-secret-name",
setupMocks: func(s *testSetup) {
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "test-repo-secret-name").Return([]byte("decrypted-data"), nil)
},
expectedResult: []byte("decrypted-data"),
},
{
name: "new service error - name starts with repo name",
namespace: "test-namespace",
nameOrValue: "test-repo-secret-name",
setupMocks: func(s *testSetup) {
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "test-repo-secret-name").Return(nil, errors.New("new service failed"))
},
expectedError: "new service failed",
},
{
name: "legacy service success - name does not start with repo name",
namespace: "test-namespace",
nameOrValue: "legacy-encrypted-value",
setupMocks: func(s *testSetup) {
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("legacy-encrypted-value")).Return([]byte("decrypted-legacy-data"), nil)
},
expectedResult: []byte("decrypted-legacy-data"),
},
{
name: "legacy service error - name does not start with repo name",
namespace: "test-namespace",
nameOrValue: "legacy-encrypted-value",
setupMocks: func(s *testSetup) {
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("legacy-encrypted-value")).Return(nil, errors.New("legacy service failed"))
},
expectedError: "legacy service failed",
},
{
name: "new service empty bytes - name starts with repo name",
namespace: "test-namespace",
nameOrValue: "test-repo-secret-name",
setupMocks: func(s *testSetup) {
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "test-repo-secret-name").Return([]byte{}, nil)
},
expectedResult: []byte{},
},
{
name: "legacy service empty bytes - name does not start with repo name",
namespace: "test-namespace",
nameOrValue: "legacy-encrypted-value",
setupMocks: func(s *testSetup) {
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("legacy-encrypted-value")).Return([]byte{}, nil)
},
expectedResult: []byte{},
},
{
name: "custom namespace handling - name starts with repo name",
namespace: "custom-namespace",
nameOrValue: "test-repo-secret-name",
setupMocks: func(s *testSetup) {
s.mockSecrets.EXPECT().Decrypt(s.ctx, "custom-namespace", "test-repo-secret-name").Return([]byte("test-data"), nil)
},
expectedResult: []byte("test-data"),
},
{
name: "exact repo name match - should use new service",
namespace: "test-namespace",
nameOrValue: "test-repo",
setupMocks: func(s *testSetup) {
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "test-repo").Return([]byte("exact-match-data"), nil)
},
expectedResult: []byte("exact-match-data"),
},
{
name: "partial repo name match - should use legacy service",
namespace: "test-namespace",
nameOrValue: "test-rep",
setupMocks: func(s *testSetup) {
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("test-rep")).Return([]byte("partial-match-data"), nil)
},
expectedResult: []byte("partial-match-data"),
},
{
name: "empty name - should use legacy service",
namespace: "test-namespace",
nameOrValue: "",
setupMocks: func(s *testSetup) {
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("")).Return([]byte("empty-name-data"), nil)
},
expectedResult: []byte("empty-name-data"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setup := setupTest(t, tt.namespace)
tt.setupMocks(setup)
result, err := setup.rs.Decrypt(setup.ctx, setup.repo, tt.nameOrValue)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
assert.Nil(t, result)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedResult, result)
}
})
}
}
func TestRepositorySecrets_Delete(t *testing.T) {
tests := []struct {
name string
namespace string
featureEnabled bool
setupMocks func(*testSetup)
expectedError string
}{
{
name: "new service delete success",
namespace: "test-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Delete(s.ctx, "test-namespace", "secret-to-delete").Return(nil)
},
},
{
name: "new service delete error",
namespace: "test-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Delete(s.ctx, "test-namespace", "secret-to-delete").Return(errors.New("delete failed"))
},
expectedError: "delete failed",
},
{
name: "new service secret not found - should succeed",
namespace: "test-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(true)
s.mockSecrets.EXPECT().Delete(s.ctx, "test-namespace", "non-existent-secret").Return(contracts.ErrSecureValueNotFound)
},
},
{
name: "nothing for legacy",
namespace: "custom-namespace",
featureEnabled: true,
setupMocks: func(s *testSetup) {
s.expectFeatureFlag(false)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setup := setupTest(t, tt.namespace)
tt.setupMocks(setup)
secretName := "secret-to-delete"
if tt.name == "new service secret not found - should succeed" {
secretName = "non-existent-secret"
}
err := setup.rs.Delete(setup.ctx, setup.repo, secretName)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
}
setup.mockSecrets.AssertExpectations(t)
})
}
}
@@ -1,165 +0,0 @@
package secrets
import (
"context"
"errors"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/secret"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
)
const svcName = provisioning.GROUP
//go:generate mockery --name SecureValueClient --structname MockSecureValueClient --inpackage --filename secure_value_client_mock.go --with-expecter
type SecureValueClient = secret.SecureValueClient
//go:generate mockery --name Service --structname MockService --inpackage --filename secret_mock.go --with-expecter
type Service interface {
Encrypt(ctx context.Context, namespace, name string, data string) (string, error)
Decrypt(ctx context.Context, namespace string, name string) ([]byte, error)
Delete(ctx context.Context, namespace string, name string) error
}
var _ Service = (*secretsService)(nil)
//go:generate mockery --name DecryptService --structname MockDecryptService --srcpkg=github.com/grafana/grafana/pkg/registry/apis/secret --filename decrypt_service_mock.go --with-expecter
type secretsService struct {
secureValues SecureValueClient
decryptSvc secret.DecryptService
decrypterServiceName string
}
func NewSecretsService(secretsSvc SecureValueClient, decryptSvc secret.DecryptService, grpcGrafanaServiceName string) Service {
return &secretsService{
secureValues: secretsSvc,
decryptSvc: decryptSvc,
decrypterServiceName: grpcGrafanaServiceName,
}
}
func (s *secretsService) Encrypt(ctx context.Context, namespace, name string, data string) (string, error) {
client, err := s.secureValues.Client(ctx, namespace)
if err != nil {
return "", err
}
// Try to get existing secret
existingUnstructured, err := client.Get(ctx, name, metav1.GetOptions{})
if err != nil {
// If secret doesn't exist (not found error), we'll create it
// For other errors, return the error
if !errors.Is(err, contracts.ErrSecureValueNotFound) {
// Check if it's a k8s not found error
if !isNotFoundError(err) {
return "", err
}
}
}
if existingUnstructured != nil {
// Update the value directly in the unstructured object
if err := unstructured.SetNestedField(existingUnstructured.Object, data, "spec", "value"); err != nil {
return "", err
}
// Update using dynamic client
result, err := client.Update(ctx, existingUnstructured, metav1.UpdateOptions{})
if err != nil {
return "", err
}
return result.GetName(), nil
}
decrypters := []string{svcName}
if s.decrypterServiceName != "" {
decrypters = append(decrypters, s.decrypterServiceName)
}
// Create the secret directly as unstructured
secret := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "secret.grafana.app/v1beta1",
"kind": "SecureValue",
"metadata": map[string]interface{}{
"namespace": namespace,
"name": name,
},
"spec": map[string]interface{}{
"description": "provisioning: " + name,
"value": data,
"decrypters": decrypters,
},
},
}
// Create new secret
finalSecret, err := client.Create(ctx, secret, metav1.CreateOptions{})
if err != nil {
return "", err
}
return finalSecret.GetName(), nil
}
func (s *secretsService) Decrypt(ctx context.Context, namespace string, name string) ([]byte, error) {
results, err := s.decryptSvc.Decrypt(ctx, svcName, namespace, name)
if err != nil {
return nil, err
}
if res, ok := results[name]; ok {
if res.Error() == nil {
return []byte(res.Value().DangerouslyExposeAndConsumeValue()), nil
}
return nil, res.Error()
}
return nil, contracts.ErrDecryptNotFound
}
func (s *secretsService) Delete(ctx context.Context, namespace string, name string) error {
client, err := s.secureValues.Client(ctx, namespace)
if err != nil {
return err
}
if err := client.Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
// FIXME: This is a temporary workaround until the client abstraction properly handles
// k8s not found errors. The client should normalize these errors to return contracts.ErrSecureValueNotFound
if isNotFoundError(err) {
return contracts.ErrSecureValueNotFound
}
return err
}
return nil
}
// Helper function to check if error is a not found error
// FIXME: This is a temporary workaround until the client abstraction properly handles
// k8s not found errors. The client should normalize these errors to return contracts.ErrSecureValueNotFound
func isNotFoundError(err error) bool {
if err == nil {
return false
}
// Check for Grafana's secure value not found error
if errors.Is(err, contracts.ErrSecureValueNotFound) {
return true
}
// Check for k8s not found error
if apierrors.IsNotFound(err) {
return true
}
// Fallback for generic not found error messages
return err.Error() == "not found"
}
@@ -1,203 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package secrets
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockService is an autogenerated mock type for the Service type
type MockService struct {
mock.Mock
}
type MockService_Expecter struct {
mock *mock.Mock
}
func (_m *MockService) EXPECT() *MockService_Expecter {
return &MockService_Expecter{mock: &_m.Mock}
}
// Decrypt provides a mock function with given fields: ctx, namespace, name
func (_m *MockService) Decrypt(ctx context.Context, namespace string, name string) ([]byte, error) {
ret := _m.Called(ctx, namespace, name)
if len(ret) == 0 {
panic("no return value specified for Decrypt")
}
var r0 []byte
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]byte, error)); ok {
return rf(ctx, namespace, name)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string) []byte); ok {
r0 = rf(ctx, namespace, name)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = rf(ctx, namespace, name)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockService_Decrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Decrypt'
type MockService_Decrypt_Call struct {
*mock.Call
}
// Decrypt is a helper method to define mock.On call
// - ctx context.Context
// - namespace string
// - name string
func (_e *MockService_Expecter) Decrypt(ctx interface{}, namespace interface{}, name interface{}) *MockService_Decrypt_Call {
return &MockService_Decrypt_Call{Call: _e.mock.On("Decrypt", ctx, namespace, name)}
}
func (_c *MockService_Decrypt_Call) Run(run func(ctx context.Context, namespace string, name string)) *MockService_Decrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *MockService_Decrypt_Call) Return(_a0 []byte, _a1 error) *MockService_Decrypt_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockService_Decrypt_Call) RunAndReturn(run func(context.Context, string, string) ([]byte, error)) *MockService_Decrypt_Call {
_c.Call.Return(run)
return _c
}
// Delete provides a mock function with given fields: ctx, namespace, name
func (_m *MockService) Delete(ctx context.Context, namespace string, name string) error {
ret := _m.Called(ctx, namespace, name)
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
r0 = rf(ctx, namespace, name)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type MockService_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
// - ctx context.Context
// - namespace string
// - name string
func (_e *MockService_Expecter) Delete(ctx interface{}, namespace interface{}, name interface{}) *MockService_Delete_Call {
return &MockService_Delete_Call{Call: _e.mock.On("Delete", ctx, namespace, name)}
}
func (_c *MockService_Delete_Call) Run(run func(ctx context.Context, namespace string, name string)) *MockService_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *MockService_Delete_Call) Return(_a0 error) *MockService_Delete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockService_Delete_Call) RunAndReturn(run func(context.Context, string, string) error) *MockService_Delete_Call {
_c.Call.Return(run)
return _c
}
// Encrypt provides a mock function with given fields: ctx, namespace, name, data
func (_m *MockService) Encrypt(ctx context.Context, namespace string, name string, data string) (string, error) {
ret := _m.Called(ctx, namespace, name, data)
if len(ret) == 0 {
panic("no return value specified for Encrypt")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (string, error)); ok {
return rf(ctx, namespace, name, data)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, string) string); ok {
r0 = rf(ctx, namespace, name, data)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok {
r1 = rf(ctx, namespace, name, data)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockService_Encrypt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Encrypt'
type MockService_Encrypt_Call struct {
*mock.Call
}
// Encrypt is a helper method to define mock.On call
// - ctx context.Context
// - namespace string
// - name string
// - data string
func (_e *MockService_Expecter) Encrypt(ctx interface{}, namespace interface{}, name interface{}, data interface{}) *MockService_Encrypt_Call {
return &MockService_Encrypt_Call{Call: _e.mock.On("Encrypt", ctx, namespace, name, data)}
}
func (_c *MockService_Encrypt_Call) Run(run func(ctx context.Context, namespace string, name string, data string)) *MockService_Encrypt_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string))
})
return _c
}
func (_c *MockService_Encrypt_Call) Return(_a0 string, _a1 error) *MockService_Encrypt_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockService_Encrypt_Call) RunAndReturn(run func(context.Context, string, string, string) (string, error)) *MockService_Encrypt_Call {
_c.Call.Return(run)
return _c
}
// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockService(t interface {
mock.TestingT
Cleanup(func())
}) *MockService {
mock := &MockService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -1,573 +0,0 @@
package secrets
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
"github.com/grafana/grafana/pkg/registry/apis/secret"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
)
// mockDynamicInterface implements a simplified version of the dynamic.ResourceInterface
type mockDynamicInterface struct {
dynamic.ResourceInterface
getResult *unstructured.Unstructured
getErr error
createResult *unstructured.Unstructured
createErr error
updateResult *unstructured.Unstructured
updateErr error
deleteErr error
}
func (m *mockDynamicInterface) Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
return m.getResult, m.getErr
}
func (m *mockDynamicInterface) Create(ctx context.Context, obj *unstructured.Unstructured, options metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) {
return m.createResult, m.createErr
}
func (m *mockDynamicInterface) Update(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) {
return m.updateResult, m.updateErr
}
func (m *mockDynamicInterface) Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error {
return m.deleteErr
}
func TestNewSecretsService(t *testing.T) {
mockSecretsSvc := NewMockSecureValueClient(t)
mockDecryptSvc := &secret.MockDecryptService{}
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, "")
assert.NotNil(t, svc)
assert.IsType(t, &secretsService{}, svc)
}
//nolint:gocyclo // This test is complex but it's a good test for the SecretsService.
func TestSecretsService_Encrypt(t *testing.T) {
tests := []struct {
name string
namespace string
secretName string
data string
setupMocks func(*MockSecureValueClient, *secret.MockDecryptService, *mockDynamicInterface)
expectedName string
expectedError string
}{
{
name: "successfully create new secret",
namespace: "test-namespace",
secretName: "test-secret",
data: "secret-data",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) {
// Setup client to return the mock resource interface
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil)
// Mock Get call to return not found error (secret doesn't exist)
mockResourceInterface.getResult = nil
mockResourceInterface.getErr = contracts.ErrSecureValueNotFound
// Mock Create call
mockResourceInterface.createResult = &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "test-namespace",
},
},
}
mockResourceInterface.createErr = nil
},
expectedName: "test-secret",
},
{
name: "successfully update existing secret",
namespace: "test-namespace",
secretName: "existing-secret",
data: "new-secret-data",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) {
existingSecret := &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "existing-secret",
"namespace": "test-namespace",
},
"spec": map[string]interface{}{
"description": "provisioning: existing-secret",
"decrypters": []string{svcName},
},
},
}
// Setup client to return the mock resource interface
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil)
// Mock Get call to return existing secret
mockResourceInterface.getResult = existingSecret
mockResourceInterface.getErr = nil
// Mock Update call
mockResourceInterface.updateResult = &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "existing-secret",
"namespace": "test-namespace",
},
},
}
mockResourceInterface.updateErr = nil
},
expectedName: "existing-secret",
},
{
name: "error reading existing secret",
namespace: "test-namespace",
secretName: "test-secret",
data: "secret-data",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) {
// Setup client to return the mock resource interface
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil)
// Mock Get call to return error
mockResourceInterface.getResult = nil
mockResourceInterface.getErr = errors.New("database error")
},
expectedError: "database error",
},
{
name: "error creating new secret",
namespace: "test-namespace",
secretName: "test-secret",
data: "secret-data",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) {
// Setup client to return the mock resource interface
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil)
// Mock Get call to return not found error
mockResourceInterface.getResult = nil
mockResourceInterface.getErr = contracts.ErrSecureValueNotFound
// Mock Create call to return error
mockResourceInterface.createResult = nil
mockResourceInterface.createErr = errors.New("creation failed")
},
expectedError: "creation failed",
},
{
name: "error updating existing secret",
namespace: "test-namespace",
secretName: "existing-secret",
data: "new-secret-data",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) {
existingSecret := &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "existing-secret",
"namespace": "test-namespace",
},
"spec": map[string]interface{}{
"description": "provisioning: existing-secret",
"decrypters": []string{svcName},
},
},
}
// Setup client to return the mock resource interface
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil)
// Mock Get call to return existing secret
mockResourceInterface.getResult = existingSecret
mockResourceInterface.getErr = nil
// Mock Update call to return error
mockResourceInterface.updateResult = nil
mockResourceInterface.updateErr = errors.New("update failed")
},
expectedError: "update failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecretsSvc := NewMockSecureValueClient(t)
mockDecryptSvc := &secret.MockDecryptService{}
mockResourceInterface := &mockDynamicInterface{}
tt.setupMocks(mockSecretsSvc, mockDecryptSvc, mockResourceInterface)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, "")
ctx := context.Background()
result, err := svc.Encrypt(ctx, tt.namespace, tt.secretName, tt.data)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedName, result)
}
})
}
}
func TestSecretsService_Encrypt_ClientError(t *testing.T) {
mockSecretsSvc := NewMockSecureValueClient(t)
mockDecryptSvc := &secret.MockDecryptService{}
// Setup client to return error
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(nil, errors.New("client error"))
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, "")
ctx := context.Background()
result, err := svc.Encrypt(ctx, "test-namespace", "test-secret", "secret-data")
assert.Error(t, err)
assert.Contains(t, err.Error(), "client error")
assert.Empty(t, result)
}
func TestSecretsService_Decrypt(t *testing.T) {
tests := []struct {
name string
namespace string
secretName string
setupMocks func(*MockSecureValueClient, *secret.MockDecryptService)
expectedResult []byte
expectedError string
}{
{
name: "successfully decrypt secret",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService) {
exposedValue := secretv1beta1.NewExposedSecureValue("decrypted-data")
mockResult := secret.NewDecryptResultValue(&exposedValue)
mockDecryptSvc.EXPECT().Decrypt(
mock.MatchedBy(func(ctx context.Context) bool {
// Verify that the context is not nil (the service creates a new StaticRequester)
return ctx != nil
}),
svcName,
"test-namespace",
"test-secret",
).Return(map[string]secret.DecryptResult{
"test-secret": mockResult,
}, nil)
},
expectedResult: []byte("decrypted-data"),
},
{
name: "decrypt service error",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService) {
mockDecryptSvc.EXPECT().Decrypt(
mock.MatchedBy(func(ctx context.Context) bool {
return ctx != nil
}),
svcName,
"test-namespace",
"test-secret",
).Return(nil, errors.New("decrypt service error"))
},
expectedError: "decrypt service error",
},
{
name: "secret not found in results",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService) {
mockDecryptSvc.EXPECT().Decrypt(
mock.MatchedBy(func(ctx context.Context) bool {
return ctx != nil
}),
svcName,
"test-namespace",
"test-secret",
).Return(map[string]secret.DecryptResult{}, nil)
},
expectedError: secret.ErrDecryptNotFound.Error(),
},
{
name: "decrypt result has error",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService) {
mockResult := secret.NewDecryptResultErr(errors.New("decryption failed"))
mockDecryptSvc.EXPECT().Decrypt(
mock.MatchedBy(func(ctx context.Context) bool {
return ctx != nil
}),
svcName,
"test-namespace",
"test-secret",
).Return(map[string]secret.DecryptResult{
"test-secret": mockResult,
}, nil)
},
expectedError: "decryption failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecretsSvc := NewMockSecureValueClient(t)
mockDecryptSvc := &secret.MockDecryptService{}
tt.setupMocks(mockSecretsSvc, mockDecryptSvc)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, "")
ctx := context.Background()
result, err := svc.Decrypt(ctx, tt.namespace, tt.secretName)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedResult, result)
}
})
}
}
// Test to verify that the Decrypt method creates the correct service identity context
func TestSecretsService_Decrypt_ServiceIdentityContext(t *testing.T) {
mockSecretsSvc := NewMockSecureValueClient(t)
mockDecryptSvc := &secret.MockDecryptService{}
exposedValue := secretv1beta1.NewExposedSecureValue("test-data")
mockResult := secret.NewDecryptResultValue(&exposedValue)
// Create a more detailed context matcher to verify the service identity context is created correctly
mockDecryptSvc.EXPECT().Decrypt(
mock.MatchedBy(func(ctx context.Context) bool {
// At minimum, verify the context is not nil and is different from the original
return ctx != nil
}),
svcName,
"test-namespace",
"test-secret",
).Return(map[string]secret.DecryptResult{
"test-secret": mockResult,
}, nil)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, "")
ctx := context.Background()
result, err := svc.Decrypt(ctx, "test-namespace", "test-secret")
assert.NoError(t, err)
assert.Equal(t, []byte("test-data"), result)
}
func TestSecretsService_Delete(t *testing.T) {
tests := []struct {
name string
namespace string
secretName string
setupMocks func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface)
expectedError string
}{
{
name: "delete success",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) {
// Setup client to return the mock resource interface
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil)
// Mock Delete call
mockResourceInterface.deleteErr = nil
},
},
{
name: "delete returns error",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) {
// Setup client to return the mock resource interface
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil)
// Mock Delete call to return error
mockResourceInterface.deleteErr = errors.New("delete failed")
},
expectedError: "delete failed",
},
{
name: "client error",
namespace: "test-namespace",
secretName: "test-secret",
setupMocks: func(mockSecretsSvc *MockSecureValueClient, mockDecryptSvc *secret.MockDecryptService, mockResourceInterface *mockDynamicInterface) {
// Setup client to return error
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(nil, errors.New("client error"))
},
expectedError: "client error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecretsSvc := NewMockSecureValueClient(t)
mockDecryptSvc := &secret.MockDecryptService{}
mockResourceInterface := &mockDynamicInterface{}
tt.setupMocks(mockSecretsSvc, mockDecryptSvc, mockResourceInterface)
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, "")
ctx := context.Background()
err := svc.Delete(ctx, tt.namespace, tt.secretName)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
}
})
}
}
func TestIsNotFoundError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{
name: "nil error",
err: nil,
expected: false,
},
{
name: "grafana secure value not found error",
err: contracts.ErrSecureValueNotFound,
expected: true,
},
{
name: "k8s not found error",
err: apierrors.NewNotFound(schema.GroupResource{Group: "secret.grafana.app", Resource: "securevalues"}, "test-secret"),
expected: true,
},
{
name: "generic not found error message",
err: errors.New("not found"),
expected: true,
},
{
name: "other error",
err: errors.New("internal server error"),
expected: false,
},
{
name: "wrapped grafana error",
err: errors.New("wrapped: " + contracts.ErrSecureValueNotFound.Error()),
expected: false, // wrapped errors won't match errors.Is unless properly wrapped
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isNotFoundError(tt.err)
assert.Equal(t, tt.expected, result, "isNotFoundError(%v) = %v, want %v", tt.err, result, tt.expected)
})
}
}
func TestSecretsService_Encrypt_WithK8sNotFoundError(t *testing.T) {
mockSecretsSvc := NewMockSecureValueClient(t)
mockDecryptSvc := &secret.MockDecryptService{}
mockResourceInterface := &mockDynamicInterface{}
// Setup client to return the mock resource interface
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil)
// Mock Get call to return k8s not found error
k8sNotFoundErr := apierrors.NewNotFound(schema.GroupResource{Group: "secret.grafana.app", Resource: "securevalues"}, "test-secret")
mockResourceInterface.getResult = nil
mockResourceInterface.getErr = k8sNotFoundErr
// Mock Create call to succeed
mockResourceInterface.createResult = &unstructured.Unstructured{
Object: map[string]interface{}{
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "test-namespace",
},
},
}
mockResourceInterface.createErr = nil
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, "")
ctx := context.Background()
result, err := svc.Encrypt(ctx, "test-namespace", "test-secret", "secret-data")
assert.NoError(t, err)
assert.Equal(t, "test-secret", result)
}
func TestSecretsService_Delete_WithK8sNotFoundError(t *testing.T) {
mockSecretsSvc := NewMockSecureValueClient(t)
mockDecryptSvc := &secret.MockDecryptService{}
mockResourceInterface := &mockDynamicInterface{}
// Setup client to return the mock resource interface
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil)
// Mock Delete call to return k8s not found error
k8sNotFoundErr := apierrors.NewNotFound(schema.GroupResource{Group: "secret.grafana.app", Resource: "securevalues"}, "test-secret")
mockResourceInterface.deleteErr = k8sNotFoundErr
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, "")
ctx := context.Background()
err := svc.Delete(ctx, "test-namespace", "test-secret")
// Should return contracts.ErrSecureValueNotFound instead of k8s error
assert.Error(t, err)
assert.ErrorIs(t, err, contracts.ErrSecureValueNotFound)
}
func TestSecretsService_Delete_WithGrafanaNotFoundError(t *testing.T) {
mockSecretsSvc := NewMockSecureValueClient(t)
mockDecryptSvc := &secret.MockDecryptService{}
mockResourceInterface := &mockDynamicInterface{}
// Setup client to return the mock resource interface
mockSecretsSvc.EXPECT().Client(mock.Anything, "test-namespace").Return(mockResourceInterface, nil)
// Mock Delete call to return Grafana not found error
mockResourceInterface.deleteErr = contracts.ErrSecureValueNotFound
svc := NewSecretsService(mockSecretsSvc, mockDecryptSvc, "")
ctx := context.Background()
err := svc.Delete(ctx, "test-namespace", "test-secret")
// Should return contracts.ErrSecureValueNotFound
assert.Error(t, err)
assert.ErrorIs(t, err, contracts.ErrSecureValueNotFound)
}
@@ -1,97 +0,0 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
package secrets
import (
context "context"
dynamic "k8s.io/client-go/dynamic"
mock "github.com/stretchr/testify/mock"
)
// MockSecureValueClient is an autogenerated mock type for the SecureValueClient type
type MockSecureValueClient struct {
mock.Mock
}
type MockSecureValueClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockSecureValueClient) EXPECT() *MockSecureValueClient_Expecter {
return &MockSecureValueClient_Expecter{mock: &_m.Mock}
}
// Client provides a mock function with given fields: ctx, namespace
func (_m *MockSecureValueClient) Client(ctx context.Context, namespace string) (dynamic.ResourceInterface, error) {
ret := _m.Called(ctx, namespace)
if len(ret) == 0 {
panic("no return value specified for Client")
}
var r0 dynamic.ResourceInterface
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (dynamic.ResourceInterface, error)); ok {
return rf(ctx, namespace)
}
if rf, ok := ret.Get(0).(func(context.Context, string) dynamic.ResourceInterface); ok {
r0 = rf(ctx, namespace)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(dynamic.ResourceInterface)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, namespace)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSecureValueClient_Client_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Client'
type MockSecureValueClient_Client_Call struct {
*mock.Call
}
// Client is a helper method to define mock.On call
// - ctx context.Context
// - namespace string
func (_e *MockSecureValueClient_Expecter) Client(ctx interface{}, namespace interface{}) *MockSecureValueClient_Client_Call {
return &MockSecureValueClient_Client_Call{Call: _e.mock.On("Client", ctx, namespace)}
}
func (_c *MockSecureValueClient_Client_Call) Run(run func(ctx context.Context, namespace string)) *MockSecureValueClient_Client_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *MockSecureValueClient_Client_Call) Return(_a0 dynamic.ResourceInterface, _a1 error) *MockSecureValueClient_Client_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockSecureValueClient_Client_Call) RunAndReturn(run func(context.Context, string) (dynamic.ResourceInterface, error)) *MockSecureValueClient_Client_Call {
_c.Call.Return(run)
return _c
}
// NewMockSecureValueClient creates a new instance of MockSecureValueClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockSecureValueClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockSecureValueClient {
mock := &MockSecureValueClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
+17 -8
View File
@@ -89,22 +89,31 @@ func (s *testConnector) Connect(ctx context.Context, name string, opts runtime.O
// HACK: Set the name and namespace if not set so that the temporary repository can be created
// This can be removed once we deprecate legacy secrets is deprecated or we use InLineSecureValues as we
// use the same field and repository name to detect which one to use.
if cfg.GetName() == "" {
if name == "new" {
// HACK: frontend is passing a "new" we need to remove the hack there as well
// Otherwise creation will fail as `new` is a reserved word. Not relevant here as we only "test"
name = "hack-on-hack-for-new"
if name == "new" {
// HACK: frontend is passing a "new" we need to remove the hack there as well
// Otherwise creation will fail as `new` is a reserved word. Not relevant here as we only "test"
name = "hack-on-hack-for-new"
} else {
// Copy previous secure values if they exist
old, _ := s.getter.GetRepository(ctx, name)
if old != nil && !old.Config().Secure.IsZero() {
secure := old.Config().Secure
if cfg.Secure.Token.IsZero() {
cfg.Secure.Token = secure.Token
}
if cfg.Secure.WebhookSecret.IsZero() {
cfg.Secure.WebhookSecret = secure.WebhookSecret
}
}
cfg.SetName(name)
}
cfg.SetName(name)
if cfg.GetNamespace() == "" {
cfg.SetNamespace(ns)
}
// Create a temporary repository
tmp, err := s.getter.AsRepository(ctx, &cfg)
tmp, err := s.getter.RepositoryFromConfig(ctx, &cfg)
if err != nil {
responder.Error(err)
return
+1 -1
View File
@@ -18,7 +18,7 @@ type RepoGetter interface {
// Given a repository configuration, return it as a repository instance
// This will only error for un-recoverable system errors
// the repository instance may or may not be valid/healthy
AsRepository(ctx context.Context, cfg *provisioning.Repository) (repository.Repository, error)
RepositoryFromConfig(ctx context.Context, cfg *provisioning.Repository) (repository.Repository, error)
}
type ClientGetter interface {
@@ -1,35 +0,0 @@
package webhooks
import (
"context"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/controller"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"k8s.io/apimachinery/pkg/runtime"
)
func Mutator(secrets secrets.RepositorySecrets) controller.Mutator {
return func(ctx context.Context, obj runtime.Object) error {
repo, ok := obj.(*provisioning.Repository)
if !ok {
return nil
}
if repo.Status.Webhook == nil {
return nil
}
if repo.Status.Webhook.Secret != "" {
secretName := repo.Name + webhookSecretSuffix
nameOrValue, err := secrets.Encrypt(ctx, repo, secretName, repo.Status.Webhook.Secret)
if err != nil {
return err
}
repo.Status.Webhook.EncryptedSecret = nameOrValue
repo.Status.Webhook.Secret = ""
}
return nil
}
}
@@ -1,157 +0,0 @@
package webhooks
import (
"context"
"errors"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
func TestMutator(t *testing.T) {
tests := []struct {
name string
obj runtime.Object
secret string
setupMocks func(*secrets.MockRepositorySecrets)
expectedEncryptedSecret string
expectedError string
}{
{
name: "successful secret encryption",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
Secret: "webhook-secret",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
Secret: "webhook-secret",
},
},
},
"test-repo-webhook-secret",
"webhook-secret",
).Return([]byte("encrypted-webhook-secret"), nil)
},
expectedEncryptedSecret: "encrypted-webhook-secret",
},
{
name: "encryption error",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
Secret: "webhook-secret",
},
},
},
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Encrypt(
context.Background(),
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
Secret: "webhook-secret",
},
},
},
"test-repo-webhook-secret",
"webhook-secret",
).Return(nil, errors.New("encryption failed"))
},
expectedError: "encryption failed",
},
{
name: "no webhook status",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: nil,
},
},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "empty secret",
obj: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
Secret: "",
},
},
},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
{
name: "non-repository object",
obj: &runtime.Unknown{},
setupMocks: func(_ *secrets.MockRepositorySecrets) {
// No expectations
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMocks(mockSecrets)
mutator := Mutator(mockSecrets)
err := mutator(context.Background(), tt.obj)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
// Check that secret was cleared and encrypted secret was set
if repo, ok := tt.obj.(*provisioning.Repository); ok && repo.Status.Webhook != nil {
if tt.expectedEncryptedSecret != "" {
// Secret should be cleared after encryption
assert.Empty(t, repo.Status.Webhook.Secret, "Secret should be cleared after encryption")
// EncryptedSecret should be set to the expected value
assert.Equal(t, tt.expectedEncryptedSecret, string(repo.Status.Webhook.EncryptedSecret), "EncryptedSecret should match expected value")
}
}
}
})
}
}
@@ -18,7 +18,6 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/webhooks/pullrequest"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -47,7 +46,6 @@ func isPublicURL(url string) bool {
func ProvideWebhooks(
cfg *setting.Cfg,
features featuremgmt.FeatureToggles,
repositorySecrets secrets.RepositorySecrets,
ghFactory *github.Factory,
renderer rendering.Service,
blobstore resource.ResourceClient,
@@ -79,7 +77,6 @@ func ProvideWebhooks(
render,
webhook,
urlProvider,
repositorySecrets,
ghFactory,
parsers,
[]jobs.Worker{pullRequestWorker},
@@ -95,7 +92,6 @@ type WebhookExtra struct {
render *renderConnector
webhook *webhookConnector
urlProvider func(namespace string) string
secrets secrets.RepositorySecrets
ghFactory *github.Factory
parsers resources.ParserFactory
workers []jobs.Worker
@@ -106,7 +102,6 @@ func NewWebhookExtra(
render *renderConnector,
webhook *webhookConnector,
urlProvider func(namespace string) string,
secrets secrets.RepositorySecrets,
ghFactory *github.Factory,
parsers resources.ParserFactory,
workers []jobs.Worker,
@@ -116,7 +111,6 @@ func NewWebhookExtra(
render: render,
webhook: webhook,
urlProvider: urlProvider,
secrets: secrets,
ghFactory: ghFactory,
parsers: parsers,
workers: workers,
@@ -136,9 +130,7 @@ func (e *WebhookExtra) Authorize(ctx context.Context, a authorizer.Attributes) (
// Mutators returns the mutators for the webhook extra
func (e *WebhookExtra) Mutators() []controller.Mutator {
return []controller.Mutator{
Mutator(e.secrets),
}
return nil
}
// UpdateStorage updates the storage with both render and webhook connectors
@@ -164,7 +156,7 @@ func (e *WebhookExtra) GetJobWorkers() []jobs.Worker {
}
// AsRepository delegates repository creation to the webhook connector
func (e *WebhookExtra) AsRepository(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) {
func (e *WebhookExtra) AsRepository(ctx context.Context, r *provisioning.Repository, secure repository.SecureValues) (repository.Repository, error) {
// Only handle GitHub repositories with webhooks if URL is public
if r.Spec.Type == provisioning.GitHubRepositoryType && e.isPublic {
gvr := provisioning.RepositoryResourceInfo.GroupVersionResource()
@@ -186,34 +178,33 @@ func (e *WebhookExtra) AsRepository(ctx context.Context, r *provisioning.Reposit
}
// Decrypt GitHub token if needed
ghToken := ghCfg.Token
if ghToken == "" && len(ghCfg.EncryptedToken) > 0 {
decrypted, err := e.secrets.Decrypt(ctx, r, string(ghCfg.EncryptedToken))
if err != nil {
return nil, fmt.Errorf("decrypt github token: %w", err)
}
ghToken = string(decrypted)
ghToken, err := secure.Token(ctx)
if err != nil {
return nil, fmt.Errorf("decrypt github token: %w", err)
}
webhookSecret, err := secure.WebhookSecret(ctx)
if err != nil {
return nil, fmt.Errorf("decrypt webhookSecret: %w", err)
}
gitCfg := git.RepositoryConfig{
URL: ghCfg.URL,
Branch: ghCfg.Branch,
Path: ghCfg.Path,
Token: ghToken,
EncryptedToken: ghCfg.EncryptedToken,
URL: ghCfg.URL,
Branch: ghCfg.Branch,
Path: ghCfg.Path,
Token: ghToken,
}
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg, e.secrets)
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg)
if err != nil {
return nil, fmt.Errorf("error creating git repository: %w", err)
}
basicRepo, err := github.NewGitHub(ctx, r, gitRepo, e.ghFactory, ghToken, e.secrets)
basicRepo, err := github.NewGitHub(ctx, r, gitRepo, e.ghFactory, ghToken)
if err != nil {
return nil, fmt.Errorf("error creating github repository: %w", err)
}
return NewGithubWebhookRepository(basicRepo, webhookURL, e.secrets), nil
return NewGithubWebhookRepository(basicRepo, webhookURL, webhookSecret), nil
}
return nil, nil
@@ -10,18 +10,16 @@ import (
"github.com/google/go-github/v70/github"
"github.com/google/uuid"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"github.com/grafana/grafana-app-sdk/logging"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
var subscribedEvents = []string{"push", "pull_request"}
//nolint:gosec // This is a constant for a secret suffix
const webhookSecretSuffix = "-webhook-secret"
var subscribedEvents = []string{"pull_request", "push"} // same order as slices.Sort()
type WebhookRepository interface {
Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error)
@@ -39,7 +37,7 @@ type githubWebhookRepository struct {
config *provisioning.Repository
owner string
repo string
secrets secrets.RepositorySecrets
secret common.RawSecureValue
gh pgh.Client
webhookURL string
}
@@ -47,7 +45,7 @@ type githubWebhookRepository struct {
func NewGithubWebhookRepository(
basic pgh.GithubRepository,
webhookURL string,
secrets secrets.RepositorySecrets,
secret common.RawSecureValue,
) GithubWebhookRepository {
return &githubWebhookRepository{
GithubRepository: basic,
@@ -56,7 +54,7 @@ func NewGithubWebhookRepository(
repo: basic.Repo(),
gh: basic.Client(),
webhookURL: webhookURL,
secrets: secrets,
secret: secret,
}
}
@@ -66,12 +64,11 @@ func (r *githubWebhookRepository) Webhook(ctx context.Context, req *http.Request
return nil, fmt.Errorf("unexpected webhook request")
}
secret, err := r.secrets.Decrypt(ctx, r.config, string(r.config.Status.Webhook.EncryptedSecret))
if err != nil {
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
if r.secret.IsZero() {
return nil, fmt.Errorf("missing webhook secret")
}
payload, err := github.ValidatePayload(req, secret)
payload, err := github.ValidatePayload(req, []byte(r.secret))
if err != nil {
return nil, apierrors.NewUnauthorized("invalid signature")
}
@@ -239,8 +236,6 @@ func (r *githubWebhookRepository) updateWebhook(ctx context.Context) (pgh.Webhoo
return pgh.WebhookConfig{}, false, fmt.Errorf("get webhook: %w", err)
}
hook.Secret = r.config.Status.Webhook.Secret // we always random gen this, so don't use it for mustUpdate below.
var mustUpdate bool
if hook.URL != r.webhookURL {
@@ -248,6 +243,7 @@ func (r *githubWebhookRepository) updateWebhook(ctx context.Context) (pgh.Webhoo
hook.URL = r.webhookURL
}
slices.Sort(hook.Events) // consistent order for comparison
if !slices.Equal(hook.Events, subscribedEvents) {
mustUpdate = true
hook.Events = subscribedEvents
@@ -263,7 +259,6 @@ func (r *githubWebhookRepository) updateWebhook(ctx context.Context) (pgh.Webhoo
return pgh.WebhookConfig{}, false, fmt.Errorf("could not generate secret: %w", err)
}
hook.Secret = secret.String()
if err := r.gh.EditWebhook(ctx, r.owner, r.repo, hook); err != nil {
return pgh.WebhookConfig{}, false, fmt.Errorf("edit webhook: %w", err)
}
@@ -304,10 +299,16 @@ func (r *githubWebhookRepository) OnCreate(ctx context.Context) ([]map[string]in
"value": &provisioning.WebhookStatus{
ID: hook.ID,
URL: hook.URL,
Secret: hook.Secret,
SubscribedEvents: hook.Events,
},
},
{
"op": "replace",
"path": "/secure/webhookSecret",
"value": map[string]string{
"create": hook.Secret,
},
},
}, nil
}
@@ -316,42 +317,34 @@ func (r *githubWebhookRepository) OnUpdate(ctx context.Context) ([]map[string]in
return nil, nil
}
ctx, _ = r.logger(ctx, "")
hook, _, err := r.updateWebhook(ctx)
if err != nil {
hook, changed, err := r.updateWebhook(ctx)
if err != nil || !changed {
return nil, err
}
return []map[string]interface{}{
{
"op": "replace",
"path": "/status/webhook",
"value": &provisioning.WebhookStatus{
ID: hook.ID,
URL: hook.URL,
Secret: hook.Secret,
SubscribedEvents: hook.Events,
},
// update the webhook and secret
return []map[string]any{{
"op": "replace",
"path": "/status/webhook",
"value": &provisioning.WebhookStatus{
ID: hook.ID,
URL: hook.URL,
SubscribedEvents: hook.Events,
},
}, nil
}, {
"op": "replace",
"path": "/secure/webhookSecret",
"value": map[string]string{
"create": hook.Secret,
},
}}, nil
}
func (r *githubWebhookRepository) OnDelete(ctx context.Context) error {
ctx, logger := r.logger(ctx, "")
if err := r.GithubRepository.OnDelete(ctx); err != nil {
return fmt.Errorf("on delete from basic github repository: %w", err)
}
if r.config.Status.Webhook == nil {
return nil
}
secretName := r.config.Name + webhookSecretSuffix
if err := r.secrets.Delete(ctx, r.config, secretName); err != nil {
return fmt.Errorf("delete webhook secret: %w", err)
}
logger.Info("Deleted webhook secret", "secretName", secretName)
return r.deleteWebhook(ctx)
}
@@ -14,13 +14,15 @@ import (
"strings"
"testing"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/google/uuid"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
)
func TestParseWebhooks(t *testing.T) {
@@ -114,9 +116,7 @@ func TestGitHubRepository_Webhook(t *testing.T) {
tests := []struct {
name string
config *provisioning.Repository
webhookSecret string
setupRequest func() *http.Request
mockSetup func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets)
expected *provisioning.WebhookResponse
expectedError error
}{
@@ -138,30 +138,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
expectedError: fmt.Errorf("unexpected webhook request"),
},
{
name: "secret decryption error",
config: &provisioning.Repository{
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
},
},
setupRequest: func() *http.Request {
req, _ := http.NewRequest("POST", "/webhook", nil)
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return(nil, errors.New("decryption failed"))
},
expectedError: fmt.Errorf("failed to decrypt secret: decryption failed"),
},
{
name: "invalid signature",
config: &provisioning.Repository{
@@ -171,22 +147,15 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
req, _ := http.NewRequest("POST", "/webhook", strings.NewReader("invalid payload"))
req.Header.Set("X-Hub-Signature-256", "invalid")
req.Header.Set("Content-Type", "application/json")
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: apierrors.NewUnauthorized("invalid signature"),
},
{
@@ -198,12 +167,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{}`
req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload))
@@ -218,10 +184,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusOK,
Message: "ping received",
@@ -239,12 +201,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"ref": "refs/heads/feature",
@@ -264,10 +223,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusOK,
},
@@ -287,12 +242,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"ref": "refs/heads/main",
@@ -312,10 +264,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusAccepted,
Job: &provisioning.JobSpec{
@@ -336,12 +284,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"ref": "refs/heads/main"
@@ -358,10 +303,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("missing repository in push event"),
},
{
@@ -373,12 +314,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"ref": "refs/heads/main",
@@ -398,10 +336,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("repository mismatch"),
},
{
@@ -416,12 +350,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"ref": "refs/heads/main",
@@ -441,10 +372,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusOK,
},
@@ -461,12 +388,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"action": "opened",
@@ -497,10 +421,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusAccepted,
Message: "pull request: opened",
@@ -528,12 +448,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"action": "synchronize",
@@ -564,10 +481,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusAccepted,
Message: "pull request: synchronize",
@@ -592,12 +505,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"action": "opened",
@@ -628,10 +538,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusOK,
Message: "ignoring pull request event as develop is not the configured branch",
@@ -646,12 +552,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"action": "closed",
@@ -682,10 +585,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusOK,
Message: "ignore pull request event: closed",
@@ -700,12 +599,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"action": "opened",
@@ -733,10 +629,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("missing repository in pull request event"),
},
{
@@ -746,12 +638,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
// GitHub config is intentionally missing
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"action": "opened",
@@ -782,10 +671,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("missing GitHub config"),
},
{
@@ -797,12 +682,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"action": "opened",
@@ -833,10 +715,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("repository mismatch"),
},
{
@@ -848,12 +726,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"action": "opened",
@@ -873,39 +748,8 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expectedError: fmt.Errorf("expected PR in event"),
},
{
name: "secret decryption error with new secrets store",
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("test-secret"),
},
},
},
setupRequest: func() *http.Request {
req, _ := http.NewRequest("POST", "/webhook", nil)
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "test-secret").
Return(nil, errors.New("decryption failed"))
},
expectedError: fmt.Errorf("failed to decrypt secret: decryption failed"),
},
{
name: "ping event with new secrets store",
config: &provisioning.Repository{
@@ -918,12 +762,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("test-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{}`
req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload))
@@ -938,10 +779,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "test-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusOK,
Message: "ping received",
@@ -963,12 +800,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("test-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{
"ref": "refs/heads/main",
@@ -988,10 +822,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "test-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusAccepted,
Job: &provisioning.JobSpec{
@@ -1012,12 +842,9 @@ func TestGitHubRepository_Webhook(t *testing.T) {
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
EncryptedSecret: []byte("encrypted-secret"),
},
Webhook: &provisioning.WebhookStatus{},
},
},
webhookSecret: "webhook-secret",
setupRequest: func() *http.Request {
payload := `{}`
req, _ := http.NewRequest("POST", "/webhook", strings.NewReader(payload))
@@ -1032,10 +859,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
return req
},
mockSetup: func(t *testing.T, mockSecrets *secrets.MockRepositorySecrets) {
mockSecrets.EXPECT().Decrypt(mock.Anything, mock.Anything, "encrypted-secret").
Return([]byte("webhook-secret"), nil)
},
expected: &provisioning.WebhookResponse{
Code: http.StatusNotImplemented,
Message: "unsupported messageType: team",
@@ -1045,20 +868,12 @@ func TestGitHubRepository_Webhook(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock secrets service
mockSecrets := secrets.NewMockRepositorySecrets(t)
// Set up the mock expectations
if tt.mockSetup != nil {
tt.mockSetup(t, mockSecrets)
}
// Create a GitHub repository with the test config
repo := &githubWebhookRepository{
config: tt.config,
owner: "grafana",
repo: "grafana",
secrets: mockSecrets,
config: tt.config,
owner: "grafana",
repo: "grafana",
secret: common.RawSecureValue("webhook-secret"),
}
// Call the Webhook method
@@ -1070,7 +885,7 @@ func TestGitHubRepository_Webhook(t *testing.T) {
var statusErr *apierrors.StatusError
if errors.As(tt.expectedError, &statusErr) {
var actualStatusErr *apierrors.StatusError
require.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type")
require.True(t, errors.As(err, &actualStatusErr), "Expected StatusError but got different error type: %T", err)
require.Equal(t, statusErr.Status().Message, actualStatusErr.Status().Message)
require.Equal(t, statusErr.Status().Code, actualStatusErr.Status().Code)
} else {
@@ -1097,9 +912,6 @@ func TestGitHubRepository_Webhook(t *testing.T) {
require.Nil(t, response.Job)
}
}
// Verify all mock expectations were met
mockSecrets.AssertExpectations(t)
})
}
}
@@ -1202,9 +1014,8 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
},
webhookURL: "https://example.com/webhook",
expectedHook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
Secret: "test-secret",
ID: 123,
URL: "https://example.com/webhook",
},
expectedError: nil,
},
@@ -1270,12 +1081,21 @@ func TestGitHubRepository_OnCreate(t *testing.T) {
require.NoError(t, err)
if tt.expectedHook != nil {
require.NotNil(t, hookOps)
require.Len(t, hookOps, 1)
require.Len(t, hookOps, 2)
require.Equal(t, "replace", hookOps[0]["op"])
require.Equal(t, "/status/webhook", hookOps[0]["path"])
require.Equal(t, tt.expectedHook.ID, hookOps[0]["value"].(*provisioning.WebhookStatus).ID)
require.Equal(t, tt.expectedHook.URL, hookOps[0]["value"].(*provisioning.WebhookStatus).URL)
require.NotEmpty(t, hookOps[0]["value"].(*provisioning.WebhookStatus).Secret) // Secret is randomly generated, so just check it's not empty
require.Equal(t, "replace", hookOps[1]["op"])
require.Equal(t, "/secure/webhookSecret", hookOps[1]["path"])
vals, ok := hookOps[1]["value"].(map[string]string)
require.True(t, ok, "expected webhookSecret as map")
require.Len(t, vals, 1, "with one property")
require.NotEmpty(t, vals["create"], "secret should be created")
_, err := uuid.Parse(vals["create"])
require.NoError(t, err, "the secret is a valid UUID")
} else {
require.Nil(t, hookOps)
}
@@ -1607,7 +1427,6 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
URL: "https://example.com/webhook",
Events: subscribedEvents,
}, nil)
// No EditWebhook call expected since no changes needed
},
config: &provisioning.Repository{
@@ -1618,19 +1437,18 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
Secret: "secret",
ID: 123,
URL: "https://example.com/webhook",
},
},
Secure: provisioning.SecureValues{
WebhookSecret: common.InlineSecureValue{
Name: "valid-secret",
},
},
},
webhookURL: "https://example.com/webhook",
expectedHook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
SubscribedEvents: subscribedEvents,
Secret: "secret",
},
webhookURL: "https://example.com/webhook",
expectedHook: nil, // nothing changed
expectedError: nil,
},
}
@@ -1662,17 +1480,22 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
require.NoError(t, err)
if tt.expectedHook != nil {
require.NotNil(t, hookOps)
require.Len(t, hookOps, 1)
require.Len(t, hookOps, 2)
require.Equal(t, "replace", hookOps[0]["op"])
require.Equal(t, "/status/webhook", hookOps[0]["path"])
require.Equal(t, tt.expectedHook.ID, hookOps[0]["value"].(*provisioning.WebhookStatus).ID)
require.Equal(t, tt.expectedHook.URL, hookOps[0]["value"].(*provisioning.WebhookStatus).URL)
if tt.expectedHook.Secret != "" {
require.Equal(t, tt.expectedHook.Secret, hookOps[0]["value"].(*provisioning.WebhookStatus).Secret)
} else {
require.NotEmpty(t, hookOps[0]["value"].(*provisioning.WebhookStatus).Secret) // Secret is randomly generated, so just check it's not empty
}
require.ElementsMatch(t, tt.expectedHook.SubscribedEvents, hookOps[0]["value"].(*provisioning.WebhookStatus).SubscribedEvents)
require.Equal(t, "replace", hookOps[1]["op"])
require.Equal(t, "/secure/webhookSecret", hookOps[1]["path"])
vals, ok := hookOps[1]["value"].(map[string]string)
require.True(t, ok, "expected webhookSecret as map")
require.Len(t, vals, 1, "with one property")
require.NotEmpty(t, vals["create"], "secret should be created")
_, err := uuid.Parse(vals["create"])
require.NoError(t, err, "the secret is a valid UUID")
} else {
require.Nil(t, hookOps)
}
@@ -1687,17 +1510,14 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
func TestGitHubRepository_OnDelete(t *testing.T) {
tests := []struct {
name string
setupMock func(m *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets)
setupMock func(m *github.MockClient)
config *provisioning.Repository
webhookURL string
expectedError error
}{
{
name: "successfully delete webhook",
setupMock: func(m *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
mockSecrets.EXPECT().Delete(mock.Anything, mock.Anything, mock.Anything).Return(nil)
// Mock deleting the webhook
setupMock: func(m *github.MockClient) {
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(nil)
},
@@ -1721,10 +1541,8 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
expectedError: nil,
},
{
name: "no webhook URL provided",
setupMock: func(_ *github.MockClient, mockRepo *github.MockGithubRepository, _ *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
},
name: "no webhook URL provided",
setupMock: func(_ *github.MockClient) {},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
@@ -1740,8 +1558,7 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
},
{
name: "webhook not found in status",
setupMock: func(_ *github.MockClient, mockRepo *github.MockGithubRepository, _ *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
setupMock: func(_ *github.MockClient) {
// No secrets deletion or webhook deletion mocks needed - method returns early when webhook is nil
},
config: &provisioning.Repository{
@@ -1760,35 +1577,9 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
webhookURL: "https://example.com/webhook",
expectedError: nil, // No error expected - method returns early when webhook is nil
},
{
name: "error on delete from basic github repository",
setupMock: func(_ *github.MockClient, mockRepo *github.MockGithubRepository, _ *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(fmt.Errorf("failed to delete webhook"))
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
},
},
},
webhookURL: "https://example.com/webhook",
expectedError: fmt.Errorf("on delete from basic github repository: failed to delete webhook"),
},
{
name: "error deleting webhook",
setupMock: func(m *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
mockSecrets.EXPECT().Delete(mock.Anything, mock.Anything, mock.Anything).Return(nil)
setupMock: func(m *github.MockClient) {
// Mock webhook deletion failure
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).
Return(fmt.Errorf("failed to delete webhook"))
@@ -1819,15 +1610,13 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
// Setup mock GitHub client
mockGH := github.NewMockClient(t)
mockRepo := github.NewMockGithubRepository(t)
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMock(mockGH, mockRepo, mockSecrets)
tt.setupMock(mockGH)
// Create repository with mock
repo := &githubWebhookRepository{
GithubRepository: mockRepo,
gh: mockGH,
config: tt.config,
secrets: mockSecrets,
owner: "grafana",
repo: "grafana",
webhookURL: tt.webhookURL,
@@ -1849,156 +1638,3 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
})
}
}
func TestGitHubRepository_OnDelete_WithSecrets(t *testing.T) {
tests := []struct {
name string
setupMock func(m *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets)
config *provisioning.Repository
webhookURL string
expectedError string
}{
{
name: "successful deletion with secrets",
setupMock: func(m *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
mockSecrets.EXPECT().Delete(
mock.Anything,
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
},
},
},
"test-repo"+webhookSecretSuffix,
).Return(nil)
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).Return(nil)
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
},
},
},
webhookURL: "https://example.com/webhook",
},
{
name: "secret deletion error",
setupMock: func(_ *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
mockSecrets.EXPECT().Delete(
mock.Anything,
&provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
},
},
},
"test-repo"+webhookSecretSuffix,
).Return(errors.New("failed to delete webhook secret"))
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
Status: provisioning.RepositoryStatus{
Webhook: &provisioning.WebhookStatus{
ID: 123,
URL: "https://example.com/webhook",
},
},
},
webhookURL: "https://example.com/webhook",
expectedError: "delete webhook secret: failed to delete webhook secret",
},
{
name: "no webhook URL - no secrets deletion",
setupMock: func(_ *github.MockClient, mockRepo *github.MockGithubRepository, _ *secrets.MockRepositorySecrets) {
mockRepo.On("OnDelete", mock.Anything).Return(nil)
},
config: &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "test-repo",
Namespace: "default",
},
Spec: provisioning.RepositorySpec{
GitHub: &provisioning.GitHubRepositoryConfig{
Branch: "main",
},
},
},
webhookURL: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := github.NewMockClient(t)
mockRepo := github.NewMockGithubRepository(t)
mockSecrets := secrets.NewMockRepositorySecrets(t)
tt.setupMock(mockClient, mockRepo, mockSecrets)
repo := &githubWebhookRepository{
GithubRepository: mockRepo,
gh: mockClient,
config: tt.config,
secrets: mockSecrets,
owner: "grafana",
repo: "grafana",
webhookURL: tt.webhookURL,
}
err := repo.OnDelete(context.Background())
if tt.expectedError != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.expectedError)
} else {
require.NoError(t, err)
}
mockClient.AssertExpectations(t)
mockRepo.AssertExpectations(t)
mockSecrets.AssertExpectations(t)
})
}
}
@@ -172,7 +172,7 @@ func (s *webhookConnector) updateLastEvent(ctx context.Context, repo repository.
eventAge := time.Since(lastEvent)
if repo.Config().Status.Webhook != nil && (eventAge > time.Minute) {
patchOp := map[string]interface{}{
patchOp := map[string]any{
"op": "replace",
"path": "/status/webhook/lastEvent",
"value": time.Now().UnixMilli(),
-2
View File
@@ -12,7 +12,6 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage"
"github.com/grafana/grafana/pkg/registry/apis/ofrep"
"github.com/grafana/grafana/pkg/registry/apis/provisioning"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/registry/apis/query"
"github.com/grafana/grafana/pkg/registry/apis/secret"
"github.com/grafana/grafana/pkg/registry/apis/service"
@@ -45,7 +44,6 @@ var WireSet = wire.NewSet(
datasource.RegisterAPIService,
folders.RegisterAPIService,
iam.RegisterAPIService,
secrets.ProvideRepositorySecrets,
provisioning.RegisterAPIService,
service.RegisterAPIService,
query.RegisterAPIService,