Provisioning: Add Connection resource (#115272)

* Provisioning: Add Connection resource

* adding some more integration tests

* updating openapi snapshot, linting

* generating FE code, fixing issue in unit tests

* addressing comments

* addressing comments

* adding more integration tests

* fixing rebase issues

* removing linting exception

* addressing comments: improving validation and tests

* adding Connection URL at mutation time, updating tests accordingly

* linting
This commit is contained in:
Daniele Stefano Ferru
2025-12-16 14:37:07 +01:00
committed by GitHub
parent c0295d06a3
commit 5ecfc79e14
37 changed files with 4305 additions and 121 deletions
@@ -39,6 +39,10 @@ func (m mockProvisioningV0alpha1Interface) Jobs(namespace string) client.JobInte
panic("not needed for testing")
}
func (m mockProvisioningV0alpha1Interface) Connections(namespace string) client.ConnectionInterface {
panic("not needed for testing")
}
func (m mockProvisioningV0alpha1Interface) Repositories(namespace string) client.RepositoryInterface {
if m.repositoriesFunc != nil {
return m.repositoriesFunc(namespace)
@@ -31,6 +31,7 @@ import (
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection"
appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller"
clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned"
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
@@ -354,6 +355,8 @@ func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attribu
return b.authorizeSettings(id)
case provisioning.JobResourceInfo.GetName(), provisioning.HistoricJobResourceInfo.GetName():
return b.authorizeJobs(id)
case provisioning.ConnectionResourceInfo.GetName():
return b.authorizeConnectionSubresource(a, id)
default:
return b.authorizeDefault(id)
}
@@ -437,6 +440,28 @@ func (b *APIBuilder) authorizeJobs(id identity.Requester) (authorizer.Decision,
return authorizer.DecisionDeny, "admin role is required", nil
}
// authorizeRepositorySubresource handles authorization for connections subresources.
func (b *APIBuilder) authorizeConnectionSubresource(a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) {
switch a.GetSubresource() {
case "":
// Doing something with the connection itself.
if id.GetOrgRole().Includes(identity.RoleAdmin) {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "admin role is required", nil
case "status":
if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "users cannot update the status of a connection", nil
default:
if id.GetIsGrafanaAdmin() {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil
}
}
// authorizeDefault handles authorization for unmapped resources.
func (b *APIBuilder) authorizeDefault(id identity.Requester) (authorizer.Decision, string, error) {
// We haven't bothered with this kind yet.
@@ -520,10 +545,19 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
storage[provisioning.HistoricJobResourceInfo.StoragePath()] = historicJobStore
}
connectionsStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.ConnectionResourceInfo, opts.OptsGetter)
if err != nil {
return fmt.Errorf("failed to create connection storage: %w", err)
}
connectionStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, connectionsStore)
storage[provisioning.JobResourceInfo.StoragePath()] = jobStore
storage[provisioning.RepositoryResourceInfo.StoragePath()] = repositoryStorage
storage[provisioning.RepositoryResourceInfo.StoragePath("status")] = repositoryStatusStorage
storage[provisioning.ConnectionResourceInfo.StoragePath()] = connectionsStore
storage[provisioning.ConnectionResourceInfo.StoragePath("status")] = connectionStatusStorage
// TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.validator), b.VerifyAgainstExistingRepositories))
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.access)
@@ -566,6 +600,11 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis
if ok {
return nil
}
// TODO: complete this as part of https://github.com/grafana/git-ui-sync-project/issues/700
c, ok := obj.(*provisioning.Connection)
if ok {
return connectionvalidation.MutateConnection(c)
}
r, ok := obj.(*provisioning.Repository)
if !ok {
@@ -615,6 +654,11 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm
return nil
}
connection, ok := obj.(*provisioning.Connection)
if ok {
return connectionvalidation.ValidateConnection(connection)
}
// Validate Jobs
job, ok := obj.(*provisioning.Job)
if ok {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,413 @@
package provisioning
import (
"context"
"errors"
"testing"
"github.com/grafana/grafana/pkg/util/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
t.Run("should perform CRUDL requests on connection", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123456",
"installationID": "454545",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
// CREATE
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.NoError(t, err, "failed to create resource")
// READ
output, err := helper.Connections.Resource.Get(ctx, "connection", metav1.GetOptions{})
require.NoError(t, err, "failed to read back resource")
assert.Equal(t, "connection", output.GetName(), "name should be equal")
assert.Equal(t, "default", output.GetNamespace(), "namespace should be equal")
spec := output.Object["spec"].(map[string]any)
assert.Equal(t, "github", spec["type"], "type should be equal")
assert.Equal(t, "https://github.com/settings/installations/454545", spec["url"], "url should be equal")
require.Contains(t, spec, "github")
githubInfo := spec["github"].(map[string]any)
assert.Equal(t, "123456", githubInfo["appID"], "appID should be equal")
assert.Equal(t, "454545", githubInfo["installationID"], "installationID should be equal")
require.Contains(t, output.Object, "secure", "object should contain secure")
assert.Contains(t, output.Object["secure"], "privateKey", "secure should contain PrivateKey")
// LIST
list, err := helper.Connections.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "failed to list resource")
assert.Equal(t, 1, len(list.Items), "should have one connection")
assert.Equal(t, "connection", list.Items[0].GetName(), "name should be equal")
// UPDATE
updatedConnection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "456789",
"installationID": "454545",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
res, err := helper.Connections.Resource.Update(ctx, updatedConnection, metav1.UpdateOptions{})
require.NoError(t, err, "failed to update resource")
spec = res.Object["spec"].(map[string]any)
require.Contains(t, spec, "github")
githubInfo = spec["github"].(map[string]any)
assert.Equal(t, "456789", githubInfo["appID"], "appID should be updated")
// DELETE
require.NoError(t, helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}), "failed to delete resource")
list, err = helper.Connections.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "failed to list resources")
assert.Equal(t, 0, len(list.Items), "should have no connections")
})
t.Run("viewer can't create or get connection", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123456",
"installationID": "454545",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
result := helper.ViewerREST.Post().
Namespace("default").
Resource("connections").
Body(connection).
Do(t.Context())
require.NotNil(t, result.Error())
err := &k8serrors.StatusError{}
require.True(t, errors.As(result.Error(), &err))
assert.Equal(t, metav1.StatusReasonForbidden, err.Status().Reason)
assert.Contains(t, err.Status().Message, "User \"viewer\" cannot create resource \"connections\"")
assert.Contains(t, err.Status().Message, "admin role is required")
result = helper.ViewerREST.Get().
Namespace("default").
Resource("connections").
Name("connection").
Do(t.Context())
require.NotNil(t, result.Error())
err = &k8serrors.StatusError{}
require.True(t, errors.As(result.Error(), &err))
assert.Equal(t, metav1.StatusReasonForbidden, err.Status().Reason)
assert.Contains(t, err.Status().Message, "User \"viewer\" cannot get resource \"connections\"")
assert.Contains(t, err.Status().Message, "admin role is required")
})
}
func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) {
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
t.Run("should fail when type is empty", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "",
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "type must be specified")
})
t.Run("should fail when type is invalid", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "some-invalid-type",
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "spec.type: Unsupported value: \"some-invalid-type\"")
})
t.Run("should fail when type is github but 'github' field is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "github info must be specified for GitHub connection")
})
t.Run("should fail when type is github but private key is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123456",
"installationID": "454545",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "privateKey must be specified for GitHub connection")
})
t.Run("should fail when type is github but a client Secret is specified", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123456",
"installationID": "454545",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
"clientSecret": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "clientSecret is forbidden in GitHub connection")
})
t.Run("should fail when type is bitbucket but 'bitbucket' field is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "bitbucket",
},
"secure": map[string]any{
"clientSecret": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "bitbucket info must be specified in Bitbucket connection")
})
t.Run("should fail when type is bitbucket but client secret is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "bitbucket",
"bitbucket": map[string]any{
"clientID": "123456",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "clientSecret must be specified for Bitbucket connection")
})
t.Run("should fail when type is bitbucket but a private key is specified", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "bitbucket",
"bitbucket": map[string]any{
"clientID": "123456",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
"clientSecret": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "privateKey is forbidden in Bitbucket connection")
})
t.Run("should fail when type is gitlab but 'gitlab' field is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "gitlab",
},
"secure": map[string]any{
"clientSecret": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "gitlab info must be specified in Gitlab connection")
})
t.Run("should fail when type is gitlab but client secret is not there", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "gitlab",
"gitlab": map[string]any{
"clientID": "123456",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "clientSecret must be specified for Gitlab connection")
})
t.Run("should fail when type is gitlab but a private key is specified", func(t *testing.T) {
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "connection",
"namespace": "default",
},
"spec": map[string]any{
"type": "gitlab",
"gitlab": map[string]any{
"clientID": "123456",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": "someSecret",
},
"clientSecret": map[string]any{
"create": "someSecret",
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "privateKey is forbidden in Gitlab connection")
})
}
@@ -53,6 +53,7 @@ type provisioningTestHelper struct {
ProvisioningPath string
Repositories *apis.K8sResourceClient
Connections *apis.K8sResourceClient
Jobs *apis.K8sResourceClient
Folders *apis.K8sResourceClient
DashboardsV0 *apis.K8sResourceClient
@@ -703,6 +704,11 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
Namespace: "default", // actually org1
GVR: provisioning.RepositoryResourceInfo.GroupVersionResource(),
})
connections := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
GVR: provisioning.ConnectionResourceInfo.GroupVersionResource(),
})
jobs := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: "default", // actually org1
@@ -763,6 +769,7 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper
K8sTestHelper: helper,
Repositories: repositories,
Connections: connections,
AdminREST: adminClient,
EditorREST: editorClient,
ViewerREST: viewerClient,