Provisioning: Wire up repo and connection factory in controllers (#117810)

* Provisioning: Wire up repo and connection factory in controllers

* addressing comments - updating the way extras are built in operators

* addressing comments

* re-adding a switch case
This commit is contained in:
Daniele Stefano Ferru 2026-02-12 00:17:40 +01:00 committed by GitHub
parent ba9df713c6
commit 7e63391bc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 499 additions and 72 deletions

View file

@ -57,9 +57,11 @@ type ControllerConfig struct {
decryptService decrypt.DecryptService
registry prometheus.Registerer
repositoryFactory repository.Factory
RepositoryFactoryFunc func() (repository.Factory, error)
repositoryExtras []repository.Extra
RepositoryExtrasFunc func() ([]repository.Extra, error)
connectionFactory connection.Factory
ConnectionFactoryFunc func() (connection.Factory, error)
connectionExtras []connection.Extra
ConnectionExtrasFunc func() ([]connection.Extra, error)
healthMetricsRecorder controller.HealthMetricsRecorder
tracer tracing.Tracer
quotaGetter quotas.QuotaGetter
@ -424,23 +426,20 @@ func (c *ControllerConfig) RepositoryFactory() (repository.Factory, error) {
return c.repositoryFactory, nil
}
if c.RepositoryFactoryFunc != nil {
repositoryFactory, err := c.RepositoryFactoryFunc()
if err != nil {
return nil, fmt.Errorf("failed to get repository factory: %w", err)
}
c.repositoryFactory = repositoryFactory
return repositoryFactory, nil
extras, err := c.RepositoryExtras()
if err != nil {
return nil, err
}
decryptSvc, err := c.DecryptService()
if err != nil {
return nil, fmt.Errorf("setup decrypt service: %w", err)
// Build enabled types from the extras
enabledTypes := make(map[provisioning.RepositoryType]struct{})
for _, extra := range extras {
enabledTypes[extra.Type()] = struct{}{}
}
repositoryFactory, err := setupRepoFactory(c.Settings, repository.ProvideDecrypter(decryptSvc), c.provisioningClient, c.Registry())
repositoryFactory, err := repository.ProvideFactory(enabledTypes, extras)
if err != nil {
return nil, fmt.Errorf("setup repository factory: %w", err)
return nil, fmt.Errorf("create repository factory: %w", err)
}
c.repositoryFactory = repositoryFactory
@ -453,23 +452,20 @@ func (c *ControllerConfig) ConnectionFactory() (connection.Factory, error) {
return c.connectionFactory, nil
}
if c.ConnectionFactoryFunc != nil {
connectionFactory, err := c.ConnectionFactoryFunc()
if err != nil {
return nil, fmt.Errorf("failed to get connection factory: %w", err)
}
c.connectionFactory = connectionFactory
return connectionFactory, nil
extras, err := c.ConnectionExtras()
if err != nil {
return nil, err
}
decryptSvc, err := c.DecryptService()
if err != nil {
return nil, fmt.Errorf("setup decrypt service: %w", err)
// Build enabled types from the extras
enabledTypes := make(map[provisioning.ConnectionType]struct{})
for _, extra := range extras {
enabledTypes[extra.Type()] = struct{}{}
}
connectionFactory, err := setupConnectionFactory(c.Settings, connection.ProvideDecrypter(decryptSvc))
connectionFactory, err := connection.ProvideFactory(enabledTypes, extras)
if err != nil {
return nil, fmt.Errorf("setup connection factory: %w", err)
return nil, fmt.Errorf("create connection factory: %w", err)
}
c.connectionFactory = connectionFactory
@ -508,30 +504,36 @@ func (c *ControllerConfig) URLProvider() (func(ctx context.Context, namespace st
return c.urlProvider, nil
}
func setupRepoFactory(
cfg *setting.Cfg,
decrypter repository.Decrypter,
_ *client.Clientset,
registry prometheus.Registerer,
) (repository.Factory, error) {
operatorSec := cfg.SectionWithEnvOverrides("operator")
provisioningSec := cfg.SectionWithEnvOverrides("provisioning")
func (c *ControllerConfig) RepositoryExtras() ([]repository.Extra, error) {
if c.repositoryExtras != nil {
return c.repositoryExtras, nil
}
if c.RepositoryExtrasFunc != nil {
extras, err := c.RepositoryExtrasFunc()
if err != nil {
return nil, fmt.Errorf("failed to get repository extras: %w", err)
}
c.repositoryExtras = extras
return extras, nil
}
// Default OSS implementation
decryptSvc, err := c.DecryptService()
if err != nil {
return nil, fmt.Errorf("get decrypt service: %w", err)
}
decrypter := repository.ProvideDecrypter(decryptSvc)
operatorSec := c.Settings.SectionWithEnvOverrides("operator")
provisioningSec := c.Settings.SectionWithEnvOverrides("provisioning")
repoTypes := provisioningSec.Key("repository_types").Strings("|")
if len(repoTypes) == 0 {
repoTypes = []string{"github"}
}
// TODO: This depends on the different flavor of Grafana
// https://github.com/grafana/git-ui-sync-project/issues/495
extras := make([]repository.Extra, 0)
alreadyRegistered := make(map[provisioning.RepositoryType]struct{})
for _, t := range repoTypes {
if _, ok := alreadyRegistered[provisioning.RepositoryType(t)]; ok {
continue
}
alreadyRegistered[provisioning.RepositoryType(t)] = struct{}{}
switch provisioning.RepositoryType(t) {
case provisioning.GitRepositoryType:
extras = append(extras, gitrepo.Extra(decrypter))
@ -539,57 +541,55 @@ func setupRepoFactory(
var webhook *webhooks.WebhookExtraBuilder
provisioningAppURL := operatorSec.Key("provisioning_server_public_url").String()
if provisioningAppURL != "" {
webhook = webhooks.ProvideWebhooks(provisioningAppURL, registry)
webhook = webhooks.ProvideWebhooks(provisioningAppURL, c.Registry())
}
extras = append(extras, githubrepo.Extra(decrypter, githubrepo.ProvideFactory(), webhook))
case provisioning.LocalRepositoryType:
homePath := operatorSec.Key("home_path").String()
if homePath == "" {
return nil, fmt.Errorf("home_path is required in [operator] section for local repository type")
}
permittedPrefixes := operatorSec.Key("local_permitted_prefixes").Strings("|")
if len(permittedPrefixes) == 0 {
return nil, fmt.Errorf("local_permitted_prefixes is required in [operator] section for local repository type")
}
extras = append(extras, local.Extra(
homePath,
permittedPrefixes,
))
extras = append(extras, local.Extra(homePath, permittedPrefixes))
default:
return nil, fmt.Errorf("unsupported repository type: %s", t)
}
}
repoFactory, err := repository.ProvideFactory(alreadyRegistered, extras)
if err != nil {
return nil, fmt.Errorf("create repository factory: %w", err)
}
return repoFactory, nil
c.repositoryExtras = extras
return extras, nil
}
func setupConnectionFactory(
cfg *setting.Cfg,
decrypter connection.Decrypter,
) (connection.Factory, error) {
// For now, only support GitHub connections
// TODO: Add support for other connection types
func (c *ControllerConfig) ConnectionExtras() ([]connection.Extra, error) {
if c.connectionExtras != nil {
return c.connectionExtras, nil
}
if c.ConnectionExtrasFunc != nil {
extras, err := c.ConnectionExtrasFunc()
if err != nil {
return nil, fmt.Errorf("failed to get connection extras: %w", err)
}
c.connectionExtras = extras
return extras, nil
}
// Default OSS implementation
decryptSvc, err := c.DecryptService()
if err != nil {
return nil, fmt.Errorf("get decrypt service: %w", err)
}
decrypter := connection.ProvideDecrypter(decryptSvc)
extras := []connection.Extra{
githubconnection.Extra(decrypter, githubconnection.ProvideFactory()),
}
enabledTypes := map[provisioning.ConnectionType]struct{}{
provisioning.GithubConnectionType: {},
}
connectionFactory, err := connection.ProvideFactory(enabledTypes, extras)
if err != nil {
return nil, fmt.Errorf("create connection factory: %w", err)
}
return connectionFactory, nil
c.connectionExtras = extras
return extras, nil
}
func setupDecryptService(cfg *setting.Cfg, tracer tracing.Tracer, tokenExchangeClient *authn.TokenExchangeClient) (decrypt.DecryptService, error) {

View file

@ -2274,6 +2274,217 @@ func TestIntegrationConnectionController_GranularConditionReasons(t *testing.T)
})
}
func TestIntegrationConnectionController_EnterpriseWiring(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !extensions.IsEnterprise {
t.Skip("Skipping integration test when not enterprise")
}
helper := runGrafana(t)
ctx := context.Background()
t.Run("GitLab connection can be created and reconciled", func(t *testing.T) {
clientSecret := base64.StdEncoding.EncodeToString([]byte("test-client-secret"))
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "test-gitlab-connection",
"namespace": "default",
},
"spec": map[string]any{
"title": "Test GitLab Connection",
"type": string(provisioning.GitlabConnectionType),
"url": "https://gitlab.com",
"gitlab": map[string]any{
"clientID": "test-client-id",
},
},
"secure": map[string]any{
"clientSecret": map[string]any{
"create": clientSecret,
},
},
}}
// CREATE
created, err := helper.Connections.Resource.Create(ctx, connection, metav1.CreateOptions{})
require.NoError(t, err, "failed to create GitLab connection")
require.NotNil(t, created)
connectionName := created.GetName()
require.NotEmpty(t, connectionName, "connection name should not be empty")
// Cleanup
defer func() {
_ = helper.Connections.Resource.Delete(ctx, connectionName, metav1.DeleteOptions{})
}()
// READ
output, err := helper.Connections.Resource.Get(ctx, connectionName, metav1.GetOptions{})
require.NoError(t, err, "failed to read back GitLab connection")
assert.Equal(t, connectionName, output.GetName(), "name should be equal")
spec := output.Object["spec"].(map[string]any)
assert.Equal(t, string(provisioning.GitlabConnectionType), spec["type"], "type should be gitlab")
// Get typed client for status checks
restConfig := helper.Org1.Admin.NewRestConfig()
provClient, err := clientset.NewForConfig(restConfig)
require.NoError(t, err, "failed to create provisioning client")
connClient := provClient.ProvisioningV0alpha1().Connections("default")
// Wait for reconciliation - controller should process the resource
// With fake credentials, health check will fail, but reconciliation should happen
require.Eventually(t, func() bool {
updated, err := connClient.Get(ctx, connectionName, metav1.GetOptions{})
if err != nil {
return false
}
// Check that controller has reconciled (ObservedGeneration matches Generation)
// and that health check was attempted (Checked > 0)
return updated.Status.ObservedGeneration == updated.Generation &&
updated.Status.Health.Checked > 0
}, 15*time.Second, 500*time.Millisecond, "connection should be reconciled by controller")
// Verify reconciliation status
reconciled, err := connClient.Get(ctx, connectionName, metav1.GetOptions{})
require.NoError(t, err)
// Controller should have set ObservedGeneration - this proves reconciliation happened
assert.Equal(t, reconciled.Generation, reconciled.Status.ObservedGeneration,
"controller should have reconciled the connection")
// Health check should have been attempted - proves the controller processed it
assert.Greater(t, reconciled.Status.Health.Checked, int64(0),
"health check should have been attempted")
// Should have a ready condition - proves status was updated
readyCondition := meta.FindStatusCondition(reconciled.Status.Conditions, provisioning.ConditionTypeReady)
assert.NotNil(t, readyCondition, "should have ready condition")
t.Logf("GitLab connection reconciled successfully. Health: %v, ObservedGen: %d, Checked: %d",
reconciled.Status.Health.Healthy, reconciled.Status.ObservedGeneration, reconciled.Status.Health.Checked)
})
t.Run("Bitbucket connection can be created and reconciled", func(t *testing.T) {
clientSecret := base64.StdEncoding.EncodeToString([]byte("test-client-secret"))
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "test-bitbucket-connection",
"namespace": "default",
},
"spec": map[string]any{
"title": "Test Bitbucket Connection",
"type": string(provisioning.BitbucketConnectionType),
"bitbucket": map[string]any{
"clientID": "test-client-id",
},
},
"secure": map[string]any{
"clientSecret": map[string]any{
"create": clientSecret,
},
},
}}
// CREATE
created, err := helper.Connections.Resource.Create(ctx, connection, metav1.CreateOptions{})
require.NoError(t, err, "failed to create Bitbucket connection")
require.NotNil(t, created)
connectionName := created.GetName()
require.NotEmpty(t, connectionName, "connection name should not be empty")
// Cleanup
defer func() {
_ = helper.Connections.Resource.Delete(ctx, connectionName, metav1.DeleteOptions{})
}()
// READ
output, err := helper.Connections.Resource.Get(ctx, connectionName, metav1.GetOptions{})
require.NoError(t, err, "failed to read back Bitbucket connection")
assert.Equal(t, connectionName, output.GetName(), "name should be equal")
spec := output.Object["spec"].(map[string]any)
assert.Equal(t, string(provisioning.BitbucketConnectionType), spec["type"], "type should be bitbucket")
// Get typed client for status checks
restConfig := helper.Org1.Admin.NewRestConfig()
provClient, err := clientset.NewForConfig(restConfig)
require.NoError(t, err, "failed to create provisioning client")
connClient := provClient.ProvisioningV0alpha1().Connections("default")
// Wait for reconciliation
require.Eventually(t, func() bool {
updated, err := connClient.Get(ctx, connectionName, metav1.GetOptions{})
if err != nil {
return false
}
return updated.Status.ObservedGeneration == updated.Generation &&
updated.Status.Health.Checked > 0
}, 15*time.Second, 500*time.Millisecond, "connection should be reconciled by controller")
// Verify reconciliation status
reconciled, err := connClient.Get(ctx, connectionName, metav1.GetOptions{})
require.NoError(t, err)
assert.Equal(t, reconciled.Generation, reconciled.Status.ObservedGeneration,
"controller should have reconciled the connection")
assert.Greater(t, reconciled.Status.Health.Checked, int64(0),
"health check should have been attempted")
readyCondition := meta.FindStatusCondition(reconciled.Status.Conditions, provisioning.ConditionTypeReady)
assert.NotNil(t, readyCondition, "should have ready condition")
t.Logf("Bitbucket connection reconciled successfully. Health: %v, ObservedGen: %d, Checked: %d",
reconciled.Status.Health.Healthy, reconciled.Status.ObservedGeneration, reconciled.Status.Health.Checked)
})
t.Run("All connection types are supported", func(t *testing.T) {
// List all supported connection types by attempting to create connections
// This validates the factory has all expected types registered
supportedTypes := []provisioning.ConnectionType{
provisioning.GithubConnectionType,
provisioning.GitlabConnectionType,
provisioning.BitbucketConnectionType,
}
for _, connType := range supportedTypes {
t.Run(string(connType), func(t *testing.T) {
// We just check that we can create the object without factory errors
// Validation errors are expected if credentials are missing/invalid
conn := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"generateName": "test-",
"namespace": "default",
},
"spec": map[string]any{
"title": "Test Connection",
"type": string(connType),
},
}}
// Try to create - we expect validation error, not "type not supported"
_, err := helper.Connections.Resource.Create(ctx, conn, metav1.CreateOptions{})
if err != nil {
// Should be a validation error, not "type not supported"
assert.NotContains(t, err.Error(), "is not supported",
"type %s should be supported by factory", connType)
}
})
}
})
}
func verifyToken(t *testing.T, appID, token string) (bool, error) {
t.Helper()

View file

@ -17,6 +17,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
@ -1773,3 +1774,218 @@ func TestIntegrationRepositoryController_DefaultBranch(t *testing.T) {
}, 30*time.Second, 1*time.Second, "repository should have default branch")
})
}
func TestIntegrationRepositoryController_EnterpriseWiring(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !extensions.IsEnterprise {
t.Skip("Skipping integration test when not enterprise")
}
helper := runGrafana(t)
ctx := context.Background()
t.Run("GitLab repository can be created and reconciled", func(t *testing.T) {
token := base64.StdEncoding.EncodeToString([]byte("test-gitlab-token"))
repository := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": map[string]any{
"name": "test-gitlab-repo",
"namespace": "default",
},
"spec": map[string]any{
"title": "Test GitLab Repository",
"type": string(provisioning.GitLabRepositoryType),
"gitlab": map[string]any{
"url": "https://gitlab.com/test/repo.git",
"branch": "main",
"path": "dashboards",
},
},
"secure": map[string]any{
"token": map[string]any{
"create": token,
},
},
}}
// CREATE
created, err := helper.Repositories.Resource.Create(ctx, repository, metav1.CreateOptions{})
require.NoError(t, err, "failed to create GitLab repository")
require.NotNil(t, created)
repoName := created.GetName()
require.NotEmpty(t, repoName, "repository name should not be empty")
// Cleanup
defer func() {
_ = helper.Repositories.Resource.Delete(ctx, repoName, metav1.DeleteOptions{})
}()
// READ
output, err := helper.Repositories.Resource.Get(ctx, repoName, metav1.GetOptions{})
require.NoError(t, err, "failed to read back GitLab repository")
assert.Equal(t, repoName, output.GetName(), "name should be equal")
spec := output.Object["spec"].(map[string]any)
assert.Equal(t, string(provisioning.GitLabRepositoryType), spec["type"], "type should be gitlab")
// Get typed client for status checks
restConfig := helper.Org1.Admin.NewRestConfig()
provClient, err := clientset.NewForConfig(restConfig)
require.NoError(t, err, "failed to create provisioning client")
repoClient := provClient.ProvisioningV0alpha1().Repositories("default")
// Wait for reconciliation - controller should process the resource
// With fake credentials, the git operations will fail, but reconciliation should happen
require.Eventually(t, func() bool {
updated, err := repoClient.Get(ctx, repoName, metav1.GetOptions{})
if err != nil {
return false
}
// Check that controller has reconciled (ObservedGeneration matches Generation)
// and that health check was attempted (Checked > 0)
return updated.Status.ObservedGeneration == updated.Generation &&
updated.Status.Health.Checked > 0
}, 15*time.Second, 500*time.Millisecond, "repository should be reconciled by controller")
// Verify reconciliation status
reconciled, err := repoClient.Get(ctx, repoName, metav1.GetOptions{})
require.NoError(t, err)
// Controller should have set ObservedGeneration - this proves reconciliation happened
assert.Equal(t, reconciled.Generation, reconciled.Status.ObservedGeneration,
"controller should have reconciled the repository")
// Health check should have been attempted - proves the controller processed it
assert.Greater(t, reconciled.Status.Health.Checked, int64(0),
"health check should have been attempted")
// Should have a ready condition - proves status was updated
readyCondition := meta.FindStatusCondition(reconciled.Status.Conditions, provisioning.ConditionTypeReady)
assert.NotNil(t, readyCondition, "should have ready condition")
t.Logf("GitLab repository reconciled successfully. Health: %v, ObservedGen: %d, Checked: %d",
reconciled.Status.Health.Healthy, reconciled.Status.ObservedGeneration, reconciled.Status.Health.Checked)
})
t.Run("Bitbucket repository can be and reconciled", func(t *testing.T) {
token := base64.StdEncoding.EncodeToString([]byte("test-bitbucket-token"))
repository := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": map[string]any{
"name": "test-bitbucket-repo",
"namespace": "default",
},
"spec": map[string]any{
"title": "Test Bitbucket Repository",
"type": string(provisioning.BitbucketRepositoryType),
"bitbucket": map[string]any{
"url": "https://bitbucket.org/workspace/repo.git",
"branch": "main",
"path": "dashboards",
},
},
"secure": map[string]any{
"token": map[string]any{
"create": token,
},
},
}}
// CREATE
created, err := helper.Repositories.Resource.Create(ctx, repository, metav1.CreateOptions{})
require.NoError(t, err, "failed to create Bitbucket repository")
require.NotNil(t, created)
repoName := created.GetName()
require.NotEmpty(t, repoName, "repository name should not be empty")
// Cleanup
defer func() {
_ = helper.Repositories.Resource.Delete(ctx, repoName, metav1.DeleteOptions{})
}()
// READ
output, err := helper.Repositories.Resource.Get(ctx, repoName, metav1.GetOptions{})
require.NoError(t, err, "failed to read back Bitbucket repository")
assert.Equal(t, repoName, output.GetName(), "name should be equal")
spec := output.Object["spec"].(map[string]any)
assert.Equal(t, string(provisioning.BitbucketRepositoryType), spec["type"], "type should be bitbucket")
// Get typed client for status checks
restConfig := helper.Org1.Admin.NewRestConfig()
provClient, err := clientset.NewForConfig(restConfig)
require.NoError(t, err, "failed to create provisioning client")
repoClient := provClient.ProvisioningV0alpha1().Repositories("default")
// Wait for reconciliation
require.Eventually(t, func() bool {
updated, err := repoClient.Get(ctx, repoName, metav1.GetOptions{})
if err != nil {
return false
}
return updated.Status.ObservedGeneration == updated.Generation &&
updated.Status.Health.Checked > 0
}, 15*time.Second, 500*time.Millisecond, "repository should be reconciled by controller")
// Verify reconciliation status
reconciled, err := repoClient.Get(ctx, repoName, metav1.GetOptions{})
require.NoError(t, err)
assert.Equal(t, reconciled.Generation, reconciled.Status.ObservedGeneration,
"controller should have reconciled the repository")
assert.Greater(t, reconciled.Status.Health.Checked, int64(0),
"health check should have been attempted")
readyCondition := meta.FindStatusCondition(reconciled.Status.Conditions, provisioning.ConditionTypeReady)
assert.NotNil(t, readyCondition, "should have ready condition")
t.Logf("Bitbucket repository reconciled successfully. Health: %v, ObservedGen: %d, Checked: %d",
reconciled.Status.Health.Healthy, reconciled.Status.ObservedGeneration, reconciled.Status.Health.Checked)
})
t.Run("All repository types are supported", func(t *testing.T) {
// List all supported repository types
supportedTypes := []provisioning.RepositoryType{
provisioning.GitHubRepositoryType,
provisioning.GitLabRepositoryType,
provisioning.BitbucketRepositoryType,
provisioning.GitRepositoryType,
provisioning.LocalRepositoryType,
}
for _, repoType := range supportedTypes {
t.Run(string(repoType), func(t *testing.T) {
// We just check that we can create the object without factory errors
// Validation errors are expected if configuration is missing/invalid
repo := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": map[string]any{
"generateName": "test-",
"namespace": "default",
},
"spec": map[string]any{
"title": "Test Repository",
"type": string(repoType),
},
}}
// Try to create - we expect validation error, not "type not supported"
_, err := helper.Repositories.Resource.Create(ctx, repo, metav1.CreateOptions{})
if err != nil {
// Should be a validation error, not "type not supported"
assert.NotContains(t, err.Error(), "is not supported",
"type %s should be supported by factory", repoType)
}
})
}
})
}