Provisioning: delete secrets on repository deletion (#108113)
- Add hooks to git, github and github webhooks to remove the. - Implement deletion in secrets package. - Add `Mutator` interface and hooks so that we can register any mutator. - Add unit test coverage to those mutators. - Move provider specific mutation from the massive `register.go` to the respective packages (e.g. `git` , `github`, etc). - Add integration test for removal. - Change the decryption fallback to simply check for the repository prefix.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
type Mutator func(ctx context.Context, obj runtime.Object) error
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
provisioning "github.com/grafana/grafana/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"
|
||||
@@ -13,12 +14,12 @@ import (
|
||||
|
||||
type Extra interface {
|
||||
Authorize(ctx context.Context, a authorizer.Attributes) (decision authorizer.Decision, reason string, err error)
|
||||
Mutate(ctx context.Context, r *provisioning.Repository) error
|
||||
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)
|
||||
RepositoryTypes() []provisioning.RepositoryType
|
||||
Mutators() []controller.Mutator
|
||||
}
|
||||
|
||||
type ExtraBuilder func(b *APIBuilder) Extra
|
||||
|
||||
@@ -98,6 +98,7 @@ type APIBuilder struct {
|
||||
repositorySecrets secrets.RepositorySecrets
|
||||
client client.ProvisioningV0alpha1Interface
|
||||
access authlib.AccessChecker
|
||||
mutators []controller.Mutator
|
||||
statusPatcher *controller.RepositoryStatusPatcher
|
||||
// Extras provides additional functionality to the API.
|
||||
extras []Extra
|
||||
@@ -125,7 +126,13 @@ func NewAPIBuilder(
|
||||
parsers := resources.NewParserFactory(clients)
|
||||
resourceLister := resources.NewResourceLister(unified, unified, legacyMigrator, storageStatus)
|
||||
|
||||
mutators := []controller.Mutator{
|
||||
git.Mutator(repositorySecrets),
|
||||
github.Mutator(repositorySecrets),
|
||||
}
|
||||
|
||||
b := &APIBuilder{
|
||||
mutators: mutators,
|
||||
tracer: tracer,
|
||||
localFileResolver: local,
|
||||
features: features,
|
||||
@@ -151,11 +158,13 @@ func NewAPIBuilder(
|
||||
b.extras = append(b.extras, builder(b))
|
||||
}
|
||||
|
||||
// Add the available repository types from the extras
|
||||
// Add the available repository types and mutators from the extras
|
||||
for _, extra := range b.extras {
|
||||
for _, t := range extra.RepositoryTypes() {
|
||||
b.availableRepositoryTypes[t] = true
|
||||
}
|
||||
|
||||
b.mutators = append(b.mutators, extra.Mutators()...)
|
||||
}
|
||||
|
||||
return b
|
||||
@@ -464,17 +473,9 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
|
||||
r.Spec.Workflows = []provisioning.Workflow{}
|
||||
}
|
||||
|
||||
if err := b.encryptGithubToken(ctx, r); err != nil {
|
||||
return fmt.Errorf("failed to encrypt github secrets: %w", err)
|
||||
}
|
||||
|
||||
if err := b.encryptGitToken(ctx, r); err != nil {
|
||||
return fmt.Errorf("failed to encrypt git secrets: %w", err)
|
||||
}
|
||||
|
||||
// Mutate the repository with any extra mutators
|
||||
for _, extra := range b.extras {
|
||||
if err := extra.Mutate(ctx, r); err != nil {
|
||||
for _, mutator := range b.mutators {
|
||||
if err := mutator(ctx, r); err != nil {
|
||||
return fmt.Errorf("failed to mutate repository: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -482,40 +483,6 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: move this to a more appropriate place
|
||||
func (b *APIBuilder) encryptGithubToken(ctx context.Context, repo *provisioning.Repository) error {
|
||||
if repo.Spec.GitHub != nil &&
|
||||
repo.Spec.GitHub.Token != "" {
|
||||
secretName := repo.Name + "-github-token"
|
||||
|
||||
nameOrValue, err := b.repositorySecrets.Encrypt(ctx, repo, secretName, repo.Spec.GitHub.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repo.Spec.GitHub.Token = ""
|
||||
repo.Spec.GitHub.EncryptedToken = nameOrValue
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: move this to a more appropriate place
|
||||
// TODO: make this one more generic
|
||||
func (b *APIBuilder) encryptGitToken(ctx context.Context, repo *provisioning.Repository) error {
|
||||
if repo.Spec.Git != nil && repo.Spec.Git.Token != "" {
|
||||
secretName := repo.Name + "-git-token"
|
||||
nameOrValue, err := b.repositorySecrets.Encrypt(ctx, repo, secretName, repo.Spec.Git.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repo.Spec.Git.EncryptedToken = nameOrValue
|
||||
repo.Spec.Git.Token = ""
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: move logic to a more appropriate place. Probably controller/validation.go
|
||||
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
|
||||
obj := a.GetObject()
|
||||
@@ -1225,13 +1192,15 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
|
||||
token = string(decrypted)
|
||||
}
|
||||
|
||||
return git.NewGitRepository(ctx, r, git.RepositoryConfig{
|
||||
cfg := git.RepositoryConfig{
|
||||
URL: r.Spec.Git.URL,
|
||||
Branch: r.Spec.Git.Branch,
|
||||
Path: r.Spec.Git.Path,
|
||||
Token: token,
|
||||
EncryptedToken: r.Spec.Git.EncryptedToken,
|
||||
})
|
||||
}
|
||||
|
||||
return git.NewGitRepository(ctx, r, cfg, b.repositorySecrets)
|
||||
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")
|
||||
@@ -1259,12 +1228,12 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor
|
||||
EncryptedToken: ghCfg.EncryptedToken,
|
||||
}
|
||||
|
||||
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg)
|
||||
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg, b.repositorySecrets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating git repository: %w", err)
|
||||
}
|
||||
|
||||
ghRepo, err := github.NewGitHub(ctx, r, gitRepo, b.ghFactory, ghToken)
|
||||
ghRepo, err := github.NewGitHub(ctx, r, gitRepo, b.ghFactory, ghToken, b.repositorySecrets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating github repository: %w", err)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type GitRepository interface {
|
||||
repository.Writer
|
||||
repository.Reader
|
||||
repository.StageableRepository
|
||||
repository.Hooks
|
||||
URL() string
|
||||
Branch() string
|
||||
}
|
||||
|
||||
@@ -451,6 +451,168 @@ func (_c *MockGitRepository_ListRefs_Call) RunAndReturn(run func(context.Context
|
||||
return _c
|
||||
}
|
||||
|
||||
// OnCreate provides a mock function with given fields: ctx
|
||||
func (_m *MockGitRepository) 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
|
||||
}
|
||||
|
||||
// MockGitRepository_OnCreate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnCreate'
|
||||
type MockGitRepository_OnCreate_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// OnCreate is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
func (_e *MockGitRepository_Expecter) OnCreate(ctx interface{}) *MockGitRepository_OnCreate_Call {
|
||||
return &MockGitRepository_OnCreate_Call{Call: _e.mock.On("OnCreate", ctx)}
|
||||
}
|
||||
|
||||
func (_c *MockGitRepository_OnCreate_Call) Run(run func(ctx context.Context)) *MockGitRepository_OnCreate_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockGitRepository_OnCreate_Call) Return(_a0 []map[string]interface{}, _a1 error) *MockGitRepository_OnCreate_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockGitRepository_OnCreate_Call) RunAndReturn(run func(context.Context) ([]map[string]interface{}, error)) *MockGitRepository_OnCreate_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// OnDelete provides a mock function with given fields: ctx
|
||||
func (_m *MockGitRepository) 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
|
||||
}
|
||||
|
||||
// MockGitRepository_OnDelete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnDelete'
|
||||
type MockGitRepository_OnDelete_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// OnDelete is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
func (_e *MockGitRepository_Expecter) OnDelete(ctx interface{}) *MockGitRepository_OnDelete_Call {
|
||||
return &MockGitRepository_OnDelete_Call{Call: _e.mock.On("OnDelete", ctx)}
|
||||
}
|
||||
|
||||
func (_c *MockGitRepository_OnDelete_Call) Run(run func(ctx context.Context)) *MockGitRepository_OnDelete_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockGitRepository_OnDelete_Call) Return(_a0 error) *MockGitRepository_OnDelete_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockGitRepository_OnDelete_Call) RunAndReturn(run func(context.Context) error) *MockGitRepository_OnDelete_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// OnUpdate provides a mock function with given fields: ctx
|
||||
func (_m *MockGitRepository) 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
|
||||
}
|
||||
|
||||
// MockGitRepository_OnUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnUpdate'
|
||||
type MockGitRepository_OnUpdate_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// OnUpdate is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
func (_e *MockGitRepository_Expecter) OnUpdate(ctx interface{}) *MockGitRepository_OnUpdate_Call {
|
||||
return &MockGitRepository_OnUpdate_Call{Call: _e.mock.On("OnUpdate", ctx)}
|
||||
}
|
||||
|
||||
func (_c *MockGitRepository_OnUpdate_Call) Run(run func(ctx context.Context)) *MockGitRepository_OnUpdate_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockGitRepository_OnUpdate_Call) Return(_a0 []map[string]interface{}, _a1 error) *MockGitRepository_OnUpdate_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockGitRepository_OnUpdate_Call) RunAndReturn(run func(context.Context) ([]map[string]interface{}, error)) *MockGitRepository_OnUpdate_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Read provides a mock function with given fields: ctx, path, ref
|
||||
func (_m *MockGitRepository) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) {
|
||||
ret := _m.Called(ctx, path, ref)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
provisioning "github.com/grafana/grafana/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 {
|
||||
return func(ctx context.Context, obj runtime.Object) error {
|
||||
repo, ok := obj.(*provisioning.Repository)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if repo.Spec.Git == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/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
|
||||
token string
|
||||
setupMocks func(*secrets.MockRepositorySecrets)
|
||||
expectedToken string
|
||||
expectedEncryptedToken string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "successful token encryption",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
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{
|
||||
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{
|
||||
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{
|
||||
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{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Git: nil,
|
||||
},
|
||||
},
|
||||
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
|
||||
// No expectations
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Git: &provisioning.GitRepositoryConfig{
|
||||
Token: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
setupMocks: func(mockSecrets *secrets.MockRepositorySecrets) {
|
||||
// No expectations
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-repository object",
|
||||
obj: &runtime.Unknown{},
|
||||
setupMocks: func(mockSecrets *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 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/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"
|
||||
@@ -26,6 +27,9 @@ 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
|
||||
@@ -39,12 +43,14 @@ 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 {
|
||||
@@ -60,6 +66,7 @@ func NewGitRepository(
|
||||
config: config,
|
||||
gitConfig: gitConfig,
|
||||
client: client,
|
||||
secrets: secrets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -753,3 +760,23 @@ 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,6 +14,7 @@ import (
|
||||
|
||||
provisioning "github.com/grafana/grafana/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"
|
||||
@@ -290,9 +291,10 @@ 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)
|
||||
gitRepo, err := NewGitRepository(ctx, config, gitConfig, mockSecrets)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, gitRepo)
|
||||
require.Equal(t, "https://git.example.com/owner/repo.git", gitRepo.URL())
|
||||
@@ -1860,7 +1862,8 @@ func TestNewGitRepository(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gitRepo, err := NewGitRepository(ctx, config, tt.gitConfig)
|
||||
mockSecrets := secrets.NewMockRepositorySecrets(t)
|
||||
gitRepo, err := NewGitRepository(ctx, config, tt.gitConfig, mockSecrets)
|
||||
|
||||
if tt.wantError {
|
||||
require.Error(t, err)
|
||||
@@ -2220,11 +2223,19 @@ func TestGitRepository_logger(t *testing.T) {
|
||||
// First call creates the logger context
|
||||
ctx1, logger1 := gitRepo.logger(ctx, "branch1")
|
||||
|
||||
// Second call should return the existing logger
|
||||
// Second call should return the existing logger context
|
||||
ctx2, logger2 := gitRepo.logger(ctx1, "branch2")
|
||||
|
||||
// When logger context already exists, it should return the same context
|
||||
require.Equal(t, ctx1, ctx2)
|
||||
require.Equal(t, logger1, logger2)
|
||||
|
||||
// The logger should be the same instance from the existing context
|
||||
require.NotNil(t, logger1)
|
||||
require.NotNil(t, logger2)
|
||||
|
||||
// Both loggers should be functionally equivalent since they come from the same context
|
||||
// We verify this by checking that they produce the same output
|
||||
require.IsType(t, logger1, logger2)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2811,7 +2822,8 @@ func TestGitRepository_NewGitRepository_ClientError(t *testing.T) {
|
||||
Path: "configs",
|
||||
}
|
||||
|
||||
gitRepo, err := NewGitRepository(ctx, config, gitConfig)
|
||||
mockSecrets := secrets.NewMockRepositorySecrets(t)
|
||||
gitRepo, err := NewGitRepository(ctx, config, gitConfig, mockSecrets)
|
||||
|
||||
// We expect this to fail during client creation
|
||||
require.Error(t, err)
|
||||
@@ -3724,3 +3736,79 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +395,226 @@ func (_c *MockGithubRepository_LatestRef_Call) RunAndReturn(run func(context.Con
|
||||
return _c
|
||||
}
|
||||
|
||||
// ListRefs provides a mock function with given fields: ctx
|
||||
func (_m *MockGithubRepository) ListRefs(ctx context.Context) ([]v0alpha1.RefItem, error) {
|
||||
ret := _m.Called(ctx)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ListRefs")
|
||||
}
|
||||
|
||||
var r0 []v0alpha1.RefItem
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context) ([]v0alpha1.RefItem, error)); ok {
|
||||
return rf(ctx)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context) []v0alpha1.RefItem); ok {
|
||||
r0 = rf(ctx)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]v0alpha1.RefItem)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
|
||||
r1 = rf(ctx)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockGithubRepository_ListRefs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRefs'
|
||||
type MockGithubRepository_ListRefs_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// ListRefs is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
func (_e *MockGithubRepository_Expecter) ListRefs(ctx interface{}) *MockGithubRepository_ListRefs_Call {
|
||||
return &MockGithubRepository_ListRefs_Call{Call: _e.mock.On("ListRefs", ctx)}
|
||||
}
|
||||
|
||||
func (_c *MockGithubRepository_ListRefs_Call) Run(run func(ctx context.Context)) *MockGithubRepository_ListRefs_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockGithubRepository_ListRefs_Call) Return(_a0 []v0alpha1.RefItem, _a1 error) *MockGithubRepository_ListRefs_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockGithubRepository_ListRefs_Call) RunAndReturn(run func(context.Context) ([]v0alpha1.RefItem, error)) *MockGithubRepository_ListRefs_Call {
|
||||
_c.Call.Return(run)
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
provisioning "github.com/grafana/grafana/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 {
|
||||
return func(ctx context.Context, obj runtime.Object) error {
|
||||
repo, ok := obj.(*provisioning.Repository)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if repo.Spec.GitHub == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/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
|
||||
token string
|
||||
setupMocks func(*secrets.MockRepositorySecrets)
|
||||
expectedToken string
|
||||
expectedEncryptedToken string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
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",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: nil,
|
||||
},
|
||||
},
|
||||
setupMocks: func(_ *secrets.MockRepositorySecrets) {
|
||||
// No expectations
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
Token: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
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 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,19 +7,25 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/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 {
|
||||
gitRepo git.GitRepository
|
||||
config *provisioning.Repository
|
||||
gh Client // assumes github.com base URL
|
||||
secrets secrets.RepositorySecrets
|
||||
|
||||
owner string
|
||||
repo string
|
||||
@@ -36,6 +42,7 @@ type GithubRepository interface {
|
||||
repository.Reader
|
||||
repository.RepositoryWithURLs
|
||||
repository.StageableRepository
|
||||
repository.Hooks
|
||||
Owner() string
|
||||
Repo() string
|
||||
Client() Client
|
||||
@@ -47,6 +54,7 @@ func NewGitHub(
|
||||
gitRepo git.GitRepository,
|
||||
factory *Factory,
|
||||
token string,
|
||||
secrets secrets.RepositorySecrets,
|
||||
) (GithubRepository, error) {
|
||||
owner, repo, err := ParseOwnerRepoGithub(config.Spec.GitHub.URL)
|
||||
if err != nil {
|
||||
@@ -59,6 +67,7 @@ func NewGitHub(
|
||||
gh: factory.New(ctx, token), // TODO, baseURL from config
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
secrets: secrets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -252,3 +261,23 @@ func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.Fi
|
||||
func (r *githubRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) {
|
||||
return r.gitRepo.Stage(ctx, opts)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/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) {
|
||||
@@ -80,6 +81,8 @@ func TestNewGitHub(t *testing.T) {
|
||||
|
||||
gitRepo := git.NewMockGitRepository(t)
|
||||
|
||||
mockSecrets := secrets.NewMockRepositorySecrets(t)
|
||||
|
||||
// Call the function under test
|
||||
repo, err := NewGitHub(
|
||||
context.Background(),
|
||||
@@ -87,6 +90,7 @@ func TestNewGitHub(t *testing.T) {
|
||||
gitRepo,
|
||||
factory,
|
||||
tt.token,
|
||||
mockSecrets,
|
||||
)
|
||||
|
||||
// Check results
|
||||
@@ -1046,3 +1050,79 @@ func TestGitHubRepositoryAccessors(t *testing.T) {
|
||||
assert.Equal(t, mockClient, result)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@ package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/service"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
grafanasecrets "github.com/grafana/grafana/pkg/services/secrets"
|
||||
@@ -22,6 +26,7 @@ func ProvideRepositorySecrets(
|
||||
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,
|
||||
@@ -67,32 +72,36 @@ func (s *repositorySecrets) Encrypt(ctx context.Context, r *provisioning.Reposit
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// Decrypt attempts to retrieve and decrypt secret data for a repository.
|
||||
// If the provisioning secrets service feature flag is enabled, it tries the new secrets service first.
|
||||
// - On success, returns the decrypted data.
|
||||
// - On failure, falls back to the legacy secrets service.
|
||||
// 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.
|
||||
//
|
||||
// If the feature flag is disabled, it tries the legacy secrets service first.
|
||||
// - On success, returns the decrypted data.
|
||||
// - On failure, falls back to the new secrets service.
|
||||
// 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 dual-path logic is intended to support migration between secret backends.
|
||||
// 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) {
|
||||
if s.features.IsEnabled(ctx, featuremgmt.FlagProvisioningSecretsService) {
|
||||
data, err := s.secretsSvc.Decrypt(ctx, r.GetNamespace(), nameOrValue)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// If the new service fails, fall back to legacy
|
||||
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", "name", nameOrValue)
|
||||
return s.secretsSvc.Decrypt(ctx, r.GetNamespace(), nameOrValue)
|
||||
} else {
|
||||
logger.Info("Decrypting secret with legacy secrets service", "name", nameOrValue)
|
||||
return s.legacySecrets.Decrypt(ctx, []byte(nameOrValue))
|
||||
}
|
||||
}
|
||||
|
||||
// If the new service is disabled, use the legacy service first
|
||||
data, err := s.legacySecrets.Decrypt(ctx, []byte(nameOrValue))
|
||||
if err == nil {
|
||||
return data, nil
|
||||
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 s.secretsSvc.Decrypt(ctx, r.GetNamespace(), nameOrValue)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -82,6 +82,54 @@ func (_c *MockRepositorySecrets_Decrypt_Call) RunAndReturn(run func(context.Cont
|
||||
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)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/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"
|
||||
@@ -136,85 +137,101 @@ func TestRepositorySecrets_Decrypt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
namespace string
|
||||
featureEnabled bool
|
||||
nameOrValue string
|
||||
setupMocks func(*testSetup)
|
||||
expectedResult []byte
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "new service success",
|
||||
namespace: "test-namespace",
|
||||
featureEnabled: true,
|
||||
name: "new service success - name starts with repo name",
|
||||
namespace: "test-namespace",
|
||||
nameOrValue: "test-repo-secret-name",
|
||||
setupMocks: func(s *testSetup) {
|
||||
s.expectFeatureFlag(true)
|
||||
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "encrypted-value").Return([]byte("decrypted-data"), nil)
|
||||
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "test-repo-secret-name").Return([]byte("decrypted-data"), nil)
|
||||
},
|
||||
expectedResult: []byte("decrypted-data"),
|
||||
},
|
||||
{
|
||||
name: "legacy service success",
|
||||
namespace: "test-namespace",
|
||||
featureEnabled: false,
|
||||
name: "new service error - name starts with repo name",
|
||||
namespace: "test-namespace",
|
||||
nameOrValue: "test-repo-secret-name",
|
||||
setupMocks: func(s *testSetup) {
|
||||
s.expectFeatureFlag(false)
|
||||
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("encrypted-value")).Return([]byte("decrypted-legacy-data"), nil)
|
||||
},
|
||||
expectedResult: []byte("decrypted-legacy-data"),
|
||||
},
|
||||
{
|
||||
name: "new service fails, fallback to legacy succeeds",
|
||||
namespace: "test-namespace",
|
||||
featureEnabled: true,
|
||||
setupMocks: func(s *testSetup) {
|
||||
s.expectFeatureFlag(true)
|
||||
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "encrypted-value").Return(nil, errors.New("new service failed"))
|
||||
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("encrypted-value")).Return([]byte("decrypted-fallback-data"), nil)
|
||||
},
|
||||
expectedResult: []byte("decrypted-fallback-data"),
|
||||
},
|
||||
{
|
||||
name: "legacy service fails, fallback to new succeeds",
|
||||
namespace: "test-namespace",
|
||||
featureEnabled: false,
|
||||
setupMocks: func(s *testSetup) {
|
||||
s.expectFeatureFlag(false)
|
||||
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("encrypted-value")).Return(nil, errors.New("legacy service failed"))
|
||||
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "encrypted-value").Return([]byte("decrypted-new-data"), nil)
|
||||
},
|
||||
expectedResult: []byte("decrypted-new-data"),
|
||||
},
|
||||
{
|
||||
name: "both services fail (feature flag enabled)",
|
||||
namespace: "test-namespace",
|
||||
featureEnabled: true,
|
||||
setupMocks: func(s *testSetup) {
|
||||
s.expectFeatureFlag(true)
|
||||
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "encrypted-value").Return(nil, errors.New("new service failed"))
|
||||
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("encrypted-value")).Return(nil, errors.New("legacy service failed"))
|
||||
},
|
||||
expectedError: "legacy service failed",
|
||||
},
|
||||
{
|
||||
name: "both services fail (feature flag disabled)",
|
||||
namespace: "test-namespace",
|
||||
featureEnabled: false,
|
||||
setupMocks: func(s *testSetup) {
|
||||
s.expectFeatureFlag(false)
|
||||
s.mockLegacy.EXPECT().Decrypt(s.ctx, []byte("encrypted-value")).Return(nil, errors.New("legacy service failed"))
|
||||
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "encrypted-value").Return(nil, errors.New("new service failed"))
|
||||
s.mockSecrets.EXPECT().Decrypt(s.ctx, "test-namespace", "test-repo-secret-name").Return(nil, errors.New("new service failed"))
|
||||
},
|
||||
expectedError: "new service failed",
|
||||
},
|
||||
{
|
||||
name: "custom namespace handling",
|
||||
namespace: "custom-namespace",
|
||||
featureEnabled: true,
|
||||
name: "legacy service success - name does not start with repo name",
|
||||
namespace: "test-namespace",
|
||||
nameOrValue: "legacy-encrypted-value",
|
||||
setupMocks: func(s *testSetup) {
|
||||
s.expectFeatureFlag(true)
|
||||
s.mockSecrets.EXPECT().Decrypt(s.ctx, "custom-namespace", "encrypted-value").Return([]byte("test-data"), nil)
|
||||
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 {
|
||||
@@ -222,7 +239,7 @@ func TestRepositorySecrets_Decrypt(t *testing.T) {
|
||||
setup := setupTest(t, tt.namespace)
|
||||
tt.setupMocks(setup)
|
||||
|
||||
result, err := setup.rs.Decrypt(setup.ctx, setup.repo, "encrypted-value")
|
||||
result, err := setup.rs.Decrypt(setup.ctx, setup.repo, tt.nameOrValue)
|
||||
|
||||
if tt.expectedError != "" {
|
||||
assert.Error(t, err)
|
||||
@@ -235,3 +252,71 @@ func TestRepositorySecrets_Decrypt(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,14 @@ type SecureValueService interface {
|
||||
Create(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error)
|
||||
Update(ctx context.Context, newSecureValue *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, bool, error)
|
||||
Read(ctx context.Context, namespace xkube.Namespace, name string) (*secretv1beta1.SecureValue, error)
|
||||
Delete(ctx context.Context, namespace xkube.Namespace, name string) (*secretv1beta1.SecureValue, error)
|
||||
}
|
||||
|
||||
//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)
|
||||
@@ -107,3 +109,17 @@ func (s *secretsService) Decrypt(ctx context.Context, namespace string, name str
|
||||
|
||||
return nil, contracts.ErrDecryptNotFound
|
||||
}
|
||||
|
||||
func (s *secretsService) Delete(ctx context.Context, namespace string, name string) error {
|
||||
ns, err := types.ParseNamespace(namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = identity.WithServiceIdentityContext(ctx, ns.OrgID, identity.WithServiceIdentityName(svcName))
|
||||
|
||||
if _, err := s.secretsSvc.Delete(ctx, xkube.Namespace(namespace), name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -81,6 +81,54 @@ func (_c *MockService_Decrypt_Call) RunAndReturn(run func(context.Context, strin
|
||||
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)
|
||||
|
||||
@@ -410,3 +410,56 @@ func TestSecretsService_Decrypt_StaticRequesterCreation(t *testing.T) {
|
||||
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 *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService)
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "delete success",
|
||||
namespace: "test-namespace",
|
||||
secretName: "test-secret",
|
||||
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
|
||||
mockSecretsSvc.EXPECT().
|
||||
Delete(mock.Anything, xkube.Namespace("test-namespace"), "test-secret").
|
||||
Return(&secretv1beta1.SecureValue{}, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete returns error",
|
||||
namespace: "test-namespace",
|
||||
secretName: "test-secret",
|
||||
setupMocks: func(mockSecretsSvc *MockSecureValueService, mockDecryptSvc *mocks.MockDecryptService) {
|
||||
mockSecretsSvc.EXPECT().
|
||||
Delete(mock.Anything, xkube.Namespace("test-namespace"), "test-secret").
|
||||
Return(nil, errors.New("delete failed"))
|
||||
},
|
||||
expectedError: "delete failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockSecretsSvc := NewMockSecureValueService(t)
|
||||
mockDecryptSvc := &mocks.MockDecryptService{}
|
||||
|
||||
tt.setupMocks(mockSecretsSvc, mockDecryptSvc)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,66 @@ func (_c *MockSecureValueService_Create_Call) RunAndReturn(run func(context.Cont
|
||||
return _c
|
||||
}
|
||||
|
||||
// Delete provides a mock function with given fields: ctx, namespace, name
|
||||
func (_m *MockSecureValueService) Delete(ctx context.Context, namespace xkube.Namespace, name string) (*v1beta1.SecureValue, error) {
|
||||
ret := _m.Called(ctx, namespace, name)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Delete")
|
||||
}
|
||||
|
||||
var r0 *v1beta1.SecureValue
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, xkube.Namespace, string) (*v1beta1.SecureValue, error)); ok {
|
||||
return rf(ctx, namespace, name)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, xkube.Namespace, string) *v1beta1.SecureValue); ok {
|
||||
r0 = rf(ctx, namespace, name)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*v1beta1.SecureValue)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, xkube.Namespace, string) error); ok {
|
||||
r1 = rf(ctx, namespace, name)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockSecureValueService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
|
||||
type MockSecureValueService_Delete_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Delete is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - namespace xkube.Namespace
|
||||
// - name string
|
||||
func (_e *MockSecureValueService_Expecter) Delete(ctx interface{}, namespace interface{}, name interface{}) *MockSecureValueService_Delete_Call {
|
||||
return &MockSecureValueService_Delete_Call{Call: _e.mock.On("Delete", ctx, namespace, name)}
|
||||
}
|
||||
|
||||
func (_c *MockSecureValueService_Delete_Call) Run(run func(ctx context.Context, namespace xkube.Namespace, name string)) *MockSecureValueService_Delete_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(xkube.Namespace), args[2].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockSecureValueService_Delete_Call) Return(_a0 *v1beta1.SecureValue, _a1 error) *MockSecureValueService_Delete_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockSecureValueService_Delete_Call) RunAndReturn(run func(context.Context, xkube.Namespace, string) (*v1beta1.SecureValue, error)) *MockSecureValueService_Delete_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// Read provides a mock function with given fields: ctx, namespace, name
|
||||
func (_m *MockSecureValueService) Read(ctx context.Context, namespace xkube.Namespace, name string) (*v1beta1.SecureValue, error) {
|
||||
ret := _m.Called(ctx, namespace, name)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
provisioning "github.com/grafana/grafana/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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
provisioning "github.com/grafana/grafana/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")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
provisioningapis "github.com/grafana/grafana/pkg/registry/apis/provisioning"
|
||||
"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"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/git"
|
||||
@@ -128,21 +129,11 @@ func (e *WebhookExtra) Authorize(ctx context.Context, a authorizer.Attributes) (
|
||||
return e.render.Authorize(ctx, a)
|
||||
}
|
||||
|
||||
// Mutate delegates mutation to the webhook connector
|
||||
func (e *WebhookExtra) Mutate(ctx context.Context, r *provisioning.Repository) error {
|
||||
// Encrypt webhook secret if present
|
||||
if r.Status.Webhook != nil && r.Status.Webhook.Secret != "" {
|
||||
secretName := r.GetName() + "-webhook-secret"
|
||||
nameOrValue, err := e.secrets.Encrypt(ctx, r, secretName, r.Status.Webhook.Secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt webhook secret: %w", err)
|
||||
}
|
||||
|
||||
r.Status.Webhook.EncryptedSecret = nameOrValue
|
||||
r.Status.Webhook.Secret = ""
|
||||
// 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
|
||||
@@ -206,12 +197,12 @@ func (e *WebhookExtra) AsRepository(ctx context.Context, r *provisioning.Reposit
|
||||
EncryptedToken: ghCfg.EncryptedToken,
|
||||
}
|
||||
|
||||
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg)
|
||||
gitRepo, err := git.NewGitRepository(ctx, r, gitCfg, e.secrets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating git repository: %w", err)
|
||||
}
|
||||
|
||||
basicRepo, err := github.NewGitHub(ctx, r, gitRepo, e.ghFactory, ghToken)
|
||||
basicRepo, err := github.NewGitHub(ctx, r, gitRepo, e.ghFactory, ghToken, e.secrets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating github repository: %w", err)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import (
|
||||
|
||||
var subscribedEvents = []string{"push", "pull_request"}
|
||||
|
||||
//nolint:gosec // This is a constant for a secret suffix
|
||||
const webhookSecretSuffix = "-webhook-secret"
|
||||
|
||||
type WebhookRepository interface {
|
||||
Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error)
|
||||
}
|
||||
@@ -269,6 +272,7 @@ func (r *githubWebhookRepository) updateWebhook(ctx context.Context) (pgh.Webhoo
|
||||
}
|
||||
|
||||
func (r *githubWebhookRepository) deleteWebhook(ctx context.Context) error {
|
||||
logger := logging.FromContext(ctx)
|
||||
if r.config.Status.Webhook == nil {
|
||||
return fmt.Errorf("webhook not found")
|
||||
}
|
||||
@@ -279,7 +283,7 @@ func (r *githubWebhookRepository) deleteWebhook(ctx context.Context) error {
|
||||
return fmt.Errorf("delete webhook: %w", err)
|
||||
}
|
||||
|
||||
logging.FromContext(ctx).Info("webhook deleted", "url", r.config.Status.Webhook.URL, "id", id)
|
||||
logger.Info("webhook deleted", "url", r.config.Status.Webhook.URL, "id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -332,10 +336,22 @@ func (r *githubWebhookRepository) OnUpdate(ctx context.Context) ([]map[string]in
|
||||
}
|
||||
|
||||
func (r *githubWebhookRepository) OnDelete(ctx context.Context) error {
|
||||
if len(r.webhookURL) == 0 {
|
||||
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
|
||||
}
|
||||
ctx, _ = r.logger(ctx, "")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1687,19 +1687,24 @@ func TestGitHubRepository_OnUpdate(t *testing.T) {
|
||||
func TestGitHubRepository_OnDelete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupMock func(m *github.MockClient)
|
||||
setupMock func(m *github.MockClient, mockRepo *github.MockGithubRepository, mockSecrets *secrets.MockRepositorySecrets)
|
||||
config *provisioning.Repository
|
||||
webhookURL string
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
name: "successfully delete webhook",
|
||||
setupMock: func(m *github.MockClient) {
|
||||
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
|
||||
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).
|
||||
Return(nil)
|
||||
},
|
||||
config: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
Branch: "main",
|
||||
@@ -1717,19 +1722,32 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "no webhook URL provided",
|
||||
setupMock: func(m *github.MockClient) {
|
||||
// No mocks needed
|
||||
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",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
Branch: "main",
|
||||
},
|
||||
},
|
||||
},
|
||||
config: &provisioning.Repository{},
|
||||
webhookURL: "",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "webhook not found in status",
|
||||
setupMock: func(m *github.MockClient) {
|
||||
// No mocks needed
|
||||
setupMock: func(_ *github.MockClient, mockRepo *github.MockGithubRepository, _ *secrets.MockRepositorySecrets) {
|
||||
mockRepo.On("OnDelete", mock.Anything).Return(nil)
|
||||
// No secrets deletion or webhook deletion mocks needed - method returns early when webhook is nil
|
||||
},
|
||||
config: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
Branch: "main",
|
||||
@@ -1740,16 +1758,45 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
|
||||
},
|
||||
},
|
||||
webhookURL: "https://example.com/webhook",
|
||||
expectedError: fmt.Errorf("webhook not found"),
|
||||
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) {
|
||||
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 webhook deletion failure
|
||||
m.On("DeleteWebhook", mock.Anything, "grafana", "grafana", int64(123)).
|
||||
Return(fmt.Errorf("failed to delete webhook"))
|
||||
},
|
||||
config: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
GitHub: &provisioning.GitHubRepositoryConfig{
|
||||
Branch: "main",
|
||||
@@ -1771,15 +1818,19 @@ func TestGitHubRepository_OnDelete(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup mock GitHub client
|
||||
mockGH := github.NewMockClient(t)
|
||||
tt.setupMock(mockGH)
|
||||
mockRepo := github.NewMockGithubRepository(t)
|
||||
mockSecrets := secrets.NewMockRepositorySecrets(t)
|
||||
tt.setupMock(mockGH, mockRepo, mockSecrets)
|
||||
|
||||
// Create repository with mock
|
||||
repo := &githubWebhookRepository{
|
||||
gh: mockGH,
|
||||
config: tt.config,
|
||||
owner: "grafana",
|
||||
repo: "grafana",
|
||||
webhookURL: tt.webhookURL,
|
||||
GithubRepository: mockRepo,
|
||||
gh: mockGH,
|
||||
config: tt.config,
|
||||
secrets: mockSecrets,
|
||||
owner: "grafana",
|
||||
repo: "grafana",
|
||||
webhookURL: tt.webhookURL,
|
||||
}
|
||||
|
||||
// Call the OnDelete method
|
||||
@@ -1798,3 +1849,156 @@ 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ package provisioning
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
@@ -80,10 +83,11 @@ func TestIntegrationProvisioning_LegacySecrets(t *testing.T) {
|
||||
name := mustNestedString(input.Object, "metadata", "name")
|
||||
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
|
||||
require.NoError(t, err, "failed to read back resource")
|
||||
repo := unstructuredToRepository(t, output)
|
||||
|
||||
// Move encrypted token mutation
|
||||
for _, expectedField := range test.expectedFields {
|
||||
value, decrypted := encryptedField(t, secretsService, nil, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
|
||||
value, decrypted := encryptedField(t, secretsService, repo, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
|
||||
require.False(t, strings.HasPrefix(value, name), "value should not be prefixed with the repository name")
|
||||
require.Equal(t, expectedField.ExpectedDecryptedValue, decrypted)
|
||||
}
|
||||
@@ -175,17 +179,17 @@ func TestIntegrationProvisioning_Secrets_LegacyUpdate(t *testing.T) {
|
||||
// Fetch current resourceVersion
|
||||
current, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
|
||||
require.NoError(t, err, "failed to get current resource for update")
|
||||
updatedInput.Object["metadata"].(map[string]any)["resourceVersion"] =
|
||||
current.Object["metadata"].(map[string]any)["resourceVersion"]
|
||||
updatedInput.Object["metadata"].(map[string]any)["resourceVersion"] = current.Object["metadata"].(map[string]any)["resourceVersion"]
|
||||
|
||||
_, err = helper.Repositories.Resource.Update(ctx, updatedInput, updateOptions)
|
||||
require.NoError(t, err, "failed to update resource")
|
||||
|
||||
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
|
||||
require.NoError(t, err, "failed to read back resource after update")
|
||||
repo := unstructuredToRepository(t, output)
|
||||
|
||||
for _, expectedField := range test.expectedFields {
|
||||
value, decrypted := encryptedField(t, secretsService, nil, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
|
||||
value, decrypted := encryptedField(t, secretsService, repo, output.Object, expectedField.Path, expectedField.ExpectedDecryptedValue != "")
|
||||
require.False(t, strings.HasPrefix(value, name), "value should not be prefixed with the repository name")
|
||||
require.Equal(t, expectedField.ExpectedDecryptedValue, decrypted)
|
||||
}
|
||||
@@ -407,6 +411,86 @@ func TestIntegrationProvisioning_Secrets_Update(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationProvisioning_Secrets_Removal(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := runGrafana(t, useAppPlatformSecrets)
|
||||
secretsService := helper.GetEnv().RepositorySecrets
|
||||
createOptions := metav1.CreateOptions{}
|
||||
|
||||
type expectedField struct {
|
||||
Path []string
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputFile string
|
||||
values map[string]interface{}
|
||||
expectedFields []expectedField
|
||||
updatedFields []expectedField
|
||||
}{
|
||||
{
|
||||
name: "remove encrypted git token",
|
||||
inputFile: "testdata/git-readonly.json.tmpl",
|
||||
values: map[string]interface{}{
|
||||
"Token": "initial-token",
|
||||
},
|
||||
expectedFields: []expectedField{
|
||||
{
|
||||
Path: []string{"spec", "git", "encryptedToken"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove encrypted github token",
|
||||
inputFile: "testdata/github-readonly.json.tmpl",
|
||||
values: map[string]interface{}{
|
||||
"Token": "initial-token",
|
||||
},
|
||||
expectedFields: []expectedField{
|
||||
{
|
||||
Path: []string{"spec", "github", "encryptedToken"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
// Create initial resource
|
||||
input := helper.RenderObject(t, test.inputFile, test.values)
|
||||
_, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
|
||||
require.NoError(t, err, "failed to create resource")
|
||||
|
||||
name := mustNestedString(input.Object, "metadata", "name")
|
||||
output, err := helper.Repositories.Resource.Get(ctx, name, metav1.GetOptions{})
|
||||
require.NoError(t, err, "failed to read back resource")
|
||||
|
||||
repo := unstructuredToRepository(t, output)
|
||||
|
||||
// Set the same name and resourceVersion for update
|
||||
err = helper.Repositories.Resource.Delete(ctx, name, metav1.DeleteOptions{})
|
||||
require.NoError(t, err, "failed to delete resource")
|
||||
|
||||
for _, expectedField := range test.expectedFields {
|
||||
secretName, found, err := base64DecodedField(output.Object, expectedField.Path)
|
||||
require.NoError(t, err, "failed to decode base64 value")
|
||||
require.True(t, found, "secretName should be found")
|
||||
require.NotEmpty(t, secretName)
|
||||
|
||||
var lastDecrypted []byte
|
||||
require.Eventually(t, func() bool {
|
||||
lastDecrypted, err = secretsService.Decrypt(ctx, repo, secretName)
|
||||
return err != nil && errors.Is(err, contracts.ErrDecryptNotFound)
|
||||
}, 1000*time.Second, 500*time.Millisecond, "expected ErrDecryptNotFound error, got %v", lastDecrypted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func encryptedField(t *testing.T, secretsService secrets.RepositorySecrets, repo *provisioning.Repository, obj map[string]any, path []string, expectedValue bool) (string, string) {
|
||||
value, found, err := base64DecodedField(obj, path)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user