Files
grafana/pkg/tests/apis/provisioning/connection_test.go
T
Roberto Jiménez Sánchez 68bf19d840 Provisioning: handle resource version conflicts in connection CRUDL test (#116184)
fix: handle resource version conflicts in connection CRUDL test

After updating a connection resource, the controller may update the
resource status, changing the resource version. This causes the delete
operation to fail with a resource version conflict.

Add retry logic to handle conflicts gracefully by retrying the delete
operation when encountering resource version conflicts.
2026-01-13 08:53:54 +00:00

985 lines
34 KiB
Go

package provisioning
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/google/go-github/v70/github"
githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github"
"github.com/grafana/grafana/pkg/extensions"
"github.com/grafana/grafana/pkg/util/testutil"
ghmock "github.com/migueleliasweb/go-github-mock/src/mock"
"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"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned"
)
//nolint:gosec // Test RSA private key (generated for testing purposes only)
const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY-----
MIIEoQIBAAKCAQBn1MuM5hIfH6d3TNStI1ofWv/gcjQ4joi9cFijEwVLuPYkF1nD
KkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6m5T/sHfVSiEWAEUblh3cA34HVCmD
cqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyjpKOXuojJ9wN9a17D2cYU5WkXjoDC
4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7DA9T8yiZwIWPp5YesgsAPyQLCFPgM
s77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQohUID0BIxE7G5quL069RuuCZWZkoF
oPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmXAgMBAAECggEADSs4Bc7ITZo+Kytb
bfol3AQ2n8jcRrANN7mgBE7NRSVYUouDnvUlbnCC2t3QXPwLdxQa11GkygLSQ2bg
GeVDgq1o4GUJTcvxFlFCcpU/hEANI/DQsxNAQ/4wUGoLOlHaO3HPvwBblHA70gGe
Ux/xpG+lMAFAiB0EHEwZ4M0mClBEOQv3NzaFTWuBHtIMS8eid7M1q5qz9+rCgZSL
KBBHo0OvUbajG4CWl8SM6LUYapASGg+U17E+4xA3npwpIdsk+CbtX+vvX324n4kn
0EkrJqCjv8M1KiCKAP+UxwP00ywxOg4PN+x+dHI/I7xBvEKe/x6BltVSdGA+PlUK
02wagQKBgQDF7gdQLFIagPH7X7dBP6qEGxj/Ck9Qdz3S1gotPkVeq+1/UtQijYZ1
j44up/0yB2B9P4kW091n+iWcyfoU5UwBua9dHvCZP3QH05LR1ZscUHxLGjDPBASt
l2xSq0hqqNWBspb1M0eCY0Yxi65iDkj3xsI2iN35BEb1FlWdR5KGvwKBgQCGS0ce
wASWbZIPU2UoKGOQkIJU6QmLy0KZbfYkpyfE8IxGttYVEQ8puNvDDNZWHNf+LP85
c8iV6SfnWiLmu1XkG2YmJFBCCAWgJ8Mq2XQD8E+a/xcaW3NqlcC5+I2czX367j3r
69wZSxRbzR+DCfOiIkrekJImwN183ZYy2cBbKQKBgFj86IrSMmO6H5Ft+j06u5ZD
fJyF7Rz3T3NwSgkHWzbyQ4ggHEIgsRg/36P4YSzSBj6phyAdRwkNfUWdxXMJmH+a
FU7frzqnPaqbJAJ1cBRt10QI1XLtkpDdaJVObvONTtjOC3LYiEkGCzQRYeiyFXpZ
AU51gJ8JnkFotjtNR4KPAoGAehVREDlLcl0lnN0ZZspgyPk2Im6/iOA9KTH3xBZZ
ZwWu4FIyiHA7spgk4Ep5R0ttZ9oMI3SIcw/EgONGOy8uw/HMiPwWIhEc3B2JpRiO
CU6bb7JalFFyuQBudiHoyxVcY5PVovWF31CLr3DoJr4TR9+Y5H/U/XnzYCIo+w1N
exECgYBFAGKYTIeGAvhIvD5TphLpbCyeVLBIq5hRyrdRY+6Iwqdr5PGvLPKwin5+
+4CDhWPW4spq8MYPCRiMrvRSctKt/7FhVGL2vE/0VY3TcLk14qLC+2+0lnPVgnYn
u5/wOyuHp1cIBnjeN41/pluOWFBHI9xLW3ExLtmYMiecJ8VdRA==
-----END RSA PRIVATE KEY-----`
//nolint:gosec // Test RSA public key (generated for testing purposes only)
const testPublicKeyPem = `-----BEGIN PUBLIC KEY-----
MIIBITANBgkqhkiG9w0BAQEFAAOCAQ4AMIIBCQKCAQBn1MuM5hIfH6d3TNStI1of
Wv/gcjQ4joi9cFijEwVLuPYkF1nDKkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6
m5T/sHfVSiEWAEUblh3cA34HVCmDcqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyj
pKOXuojJ9wN9a17D2cYU5WkXjoDC4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7D
A9T8yiZwIWPp5YesgsAPyQLCFPgMs77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQo
hUID0BIxE7G5quL069RuuCZWZkoFoPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmX
AgMBAAE=
-----END PUBLIC KEY-----`
func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
decryptService := helper.GetEnv().DecryptService
require.NotNil(t, decryptService, "decrypt service not wired properly")
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": privateKeyBase64,
},
},
}}
// CREATE
_, err := helper.CreateGithubConnection(t, ctx, connection)
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")
// Verifying token
assert.Contains(t, output.Object["secure"], "token", "token should be created")
secretName, found, err := unstructured.NestedString(output.Object, "secure", "token", "name")
require.NoError(t, err, "error getting secret name")
require.True(t, found, "secret name should exist: %v", output.Object)
decrypted, err := decryptService.Decrypt(ctx, "provisioning.grafana.app", output.GetNamespace(), secretName)
require.NoError(t, err, "decryption error")
require.Len(t, decrypted, 1)
val := decrypted[secretName].Value()
require.NotNil(t, val)
k := val.DangerouslyExposeAndConsumeValue()
valid, err := verifyToken(t, "123456", testPublicKeyPem, k)
require.NoError(t, err, "error verifying token: %s", k)
require.True(t, valid, "token should be valid: %s", k)
// 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": "123456",
"installationID": "454546",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": privateKeyBase64,
},
},
}}
res, err := helper.UpdateGithubConnection(t, ctx, updatedConnection)
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, "454546", githubInfo["installationID"], "installationID should be updated")
// DELETE - Retry delete to handle resource version conflicts
// The controller may have updated the resource after our update, changing the resource version
require.Eventually(t, func() bool {
err := helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{})
if err != nil {
if k8serrors.IsConflict(err) {
// Resource version conflict - retry
return false
}
if k8serrors.IsNotFound(err) {
// Already deleted - success
return true
}
// Other error - fail the test
require.NoError(t, err, "failed to delete resource")
}
return true
}, 5*time.Second, 100*time.Millisecond, "should successfully 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": privateKeyBase64,
},
},
}}
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) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
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": privateKeyBase64,
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "connection type \"\" is not supported")
})
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": privateKeyBase64,
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "connection type \"some-invalid-type\" is not supported")
})
t.Run("should fail when type is 'git'", 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": "git",
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": privateKeyBase64,
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "connection type \"git\" is not supported")
})
t.Run("should fail when type is 'local'", 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": "local",
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": privateKeyBase64,
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "connection type \"local\" is not supported")
})
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": privateKeyBase64,
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "invalid 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 also 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": privateKeyBase64,
},
"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 github and github API is unavailable", func(t *testing.T) {
connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory)
connectionFactory.Client = ghmock.NewMockedHTTPClient(
ghmock.WithRequestMatchHandler(
ghmock.GetApp,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{
Response: &http.Response{
StatusCode: http.StatusServiceUnavailable,
},
Message: "Service unavailable",
}))
}),
),
)
helper.SetGithubConnectionFactory(connectionFactory)
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": privateKeyBase64,
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "spec.token: Internal error: github is unavailable")
})
t.Run("should fail when type is github and returned app ID doesn't match given one", func(t *testing.T) {
var appID int64 = 123455
appSlug := "appSlug"
connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory)
connectionFactory.Client = ghmock.NewMockedHTTPClient(
ghmock.WithRequestMatch(
ghmock.GetApp, github.App{
ID: &appID,
Slug: &appSlug,
},
),
)
helper.SetGithubConnectionFactory(connectionFactory)
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": privateKeyBase64,
},
},
}}
_, err := helper.Connections.Resource.Create(ctx, connection, createOptions)
require.Error(t, err, "failed to create resource")
assert.Contains(t, err.Error(), "spec.appID: Invalid value: \"123456\": appID mismatch")
})
}
func TestIntegrationProvisioning_ConnectionEnterpriseValidation(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
if !extensions.IsEnterprise {
t.Skip("Skipping integration test when not enterprise")
}
helper := runGrafana(t)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
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(), "invalid 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(), "invalid 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")
})
}
func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
namespace := "default"
// Create typed client from REST config
restConfig := helper.Org1.Admin.NewRestConfig()
provisioningClient, err := clientset.NewForConfig(restConfig)
require.NoError(t, err)
connClient := provisioningClient.ProvisioningV0alpha1().Connections(namespace)
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
t.Run("health check gets updated after initial creation", func(t *testing.T) {
// Create a connection using unstructured (like other connection tests)
connUnstructured := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "test-connection-health",
"namespace": namespace,
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "12345",
"installationID": "67890",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": privateKeyBase64,
},
},
}}
createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured)
require.NoError(t, err)
require.NotNil(t, createdUnstructured)
connName := createdUnstructured.GetName()
t.Cleanup(func() {
_ = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{})
})
// Wait for initial reconciliation - controller should update status
require.Eventually(t, func() bool {
updated, err := connClient.Get(ctx, connName, metav1.GetOptions{})
if err != nil {
return false
}
return updated.Status.ObservedGeneration == updated.Generation &&
updated.Status.Health.Checked > 0 &&
updated.Status.State == provisioning.ConnectionStateConnected &&
updated.Status.Health.Healthy
}, 10*time.Second, 500*time.Millisecond, "connection should be initially reconciled with health status")
// Verify initial health check was set
initial, err := connClient.Get(ctx, connName, metav1.GetOptions{})
require.NoError(t, err)
assert.True(t, initial.Status.Health.Healthy, "connection should be healthy")
assert.Equal(t, provisioning.ConnectionStateConnected, initial.Status.State, "connection should be connected")
assert.Greater(t, initial.Status.Health.Checked, int64(0), "health check timestamp should be set")
assert.Equal(t, initial.Generation, initial.Status.ObservedGeneration, "observed generation should match")
})
t.Run("health check updates when spec changes", func(t *testing.T) {
// Create a connection using unstructured
connUnstructured := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "test-connection-spec-change",
"namespace": namespace,
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "11111",
"installationID": "22222",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": privateKeyBase64,
},
},
}}
createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured)
require.NoError(t, err)
require.NotNil(t, createdUnstructured)
connName := createdUnstructured.GetName()
t.Cleanup(func() {
_ = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{})
})
// Wait for initial reconciliation
var initialHealthChecked int64
require.Eventually(t, func() bool {
updated, err := connClient.Get(ctx, connName, metav1.GetOptions{})
if err != nil {
return false
}
if updated.Status.ObservedGeneration == updated.Generation {
initialHealthChecked = updated.Status.Health.Checked
return true
}
return false
}, 10*time.Second, 500*time.Millisecond, "connection should be initially reconciled")
// Get the latest version before updating to avoid conflicts with controller updates
latestUnstructured, err := helper.Connections.Resource.Get(ctx, connName, metav1.GetOptions{})
require.NoError(t, err)
// Update the connection spec using the latest version
updatedUnstructured := latestUnstructured.DeepCopy()
githubSpec := updatedUnstructured.Object["spec"].(map[string]any)["github"].(map[string]any)
githubSpec["appID"] = "99999"
_, err = helper.UpdateGithubConnection(t, ctx, updatedUnstructured)
require.NoError(t, err)
// Wait for reconciliation after spec change
require.Eventually(t, func() bool {
reconciled, err := connClient.Get(ctx, connName, metav1.GetOptions{})
if err != nil {
return false
}
return reconciled.Status.ObservedGeneration == reconciled.Generation &&
reconciled.Status.Health.Checked > initialHealthChecked
}, 10*time.Second, 500*time.Millisecond, "connection should be reconciled after spec change")
// Verify health check was updated
final, err := connClient.Get(ctx, connName, metav1.GetOptions{})
require.NoError(t, err)
assert.Equal(t, final.Generation, final.Status.ObservedGeneration, "observed generation should match generation")
assert.Greater(t, final.Status.Health.Checked, initialHealthChecked, "health check should be updated after spec change")
assert.True(t, final.Status.Health.Healthy, "connection should remain healthy")
})
}
func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
helper := runGrafana(t)
ctx := context.Background()
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM))
// Create a connection first
connection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Connection",
"metadata": map[string]any{
"name": "test-conn-for-field-selector",
"namespace": "default",
},
"spec": map[string]any{
"type": "github",
"github": map[string]any{
"appID": "123456",
"installationID": "789012",
},
},
"secure": map[string]any{
"privateKey": map[string]any{
"create": privateKeyBase64,
},
},
}}
_, err := helper.CreateGithubConnection(t, ctx, connection)
require.NoError(t, err, "failed to create connection")
t.Cleanup(func() {
// Clean up repositories first
_ = helper.Repositories.Resource.Delete(ctx, "repo-with-connection", metav1.DeleteOptions{})
_ = helper.Repositories.Resource.Delete(ctx, "repo-without-connection", metav1.DeleteOptions{})
_ = helper.Repositories.Resource.Delete(ctx, "repo-with-different-connection", metav1.DeleteOptions{})
// Then clean up the connection
_ = helper.Connections.Resource.Delete(ctx, "test-conn-for-field-selector", metav1.DeleteOptions{})
})
// Create a repository WITH the connection
repoWithConnection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": map[string]any{
"name": "repo-with-connection",
"namespace": "default",
},
"spec": map[string]any{
"title": "Repo With Connection",
"type": "local",
"sync": map[string]any{
"enabled": false,
"target": "folder",
},
"local": map[string]any{
"path": helper.ProvisioningPath,
},
"connection": map[string]any{
"name": "test-conn-for-field-selector",
},
},
}}
_, err = helper.Repositories.Resource.Create(ctx, repoWithConnection, createOptions)
require.NoError(t, err, "failed to create repository with connection")
// Create a repository WITHOUT the connection
repoWithoutConnection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": map[string]any{
"name": "repo-without-connection",
"namespace": "default",
},
"spec": map[string]any{
"title": "Repo Without Connection",
"type": "local",
"sync": map[string]any{
"enabled": false,
"target": "folder",
},
"local": map[string]any{
"path": helper.ProvisioningPath,
},
},
}}
_, err = helper.Repositories.Resource.Create(ctx, repoWithoutConnection, createOptions)
require.NoError(t, err, "failed to create repository without connection")
// Create a repository with a DIFFERENT connection name (non-existent)
repoWithDifferentConnection := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": map[string]any{
"name": "repo-with-different-connection",
"namespace": "default",
},
"spec": map[string]any{
"title": "Repo With Different Connection",
"type": "local",
"sync": map[string]any{
"enabled": false,
"target": "folder",
},
"local": map[string]any{
"path": helper.ProvisioningPath,
},
"connection": map[string]any{
"name": "some-other-connection",
},
},
}}
_, err = helper.Repositories.Resource.Create(ctx, repoWithDifferentConnection, createOptions)
require.NoError(t, err, "failed to create repository with different connection")
t.Run("filter repositories by spec.connection.name", func(t *testing.T) {
// List repositories with field selector for the specific connection
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
FieldSelector: "spec.connection.name=test-conn-for-field-selector",
})
require.NoError(t, err, "failed to list repositories with field selector")
// Should only return the repository with the matching connection
assert.Len(t, list.Items, 1, "should return exactly one repository")
assert.Equal(t, "repo-with-connection", list.Items[0].GetName(), "should return the correct repository")
})
t.Run("filter repositories by non-existent connection returns empty", func(t *testing.T) {
// List repositories with field selector for a non-existent connection
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
FieldSelector: "spec.connection.name=non-existent-connection",
})
require.NoError(t, err, "failed to list repositories with field selector")
// Should return empty list
assert.Len(t, list.Items, 0, "should return no repositories for non-existent connection")
})
t.Run("filter repositories by empty connection name", func(t *testing.T) {
// List repositories with field selector for empty connection (repos without connection)
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{
FieldSelector: "spec.connection.name=",
})
require.NoError(t, err, "failed to list repositories with empty connection field selector")
// Should return the repository without a connection
assert.Len(t, list.Items, 1, "should return exactly one repository without connection")
assert.Equal(t, "repo-without-connection", list.Items[0].GetName(), "should return the repository without connection")
})
t.Run("list all repositories without field selector", func(t *testing.T) {
// List all repositories without field selector
list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "failed to list all repositories")
// Should return all three repositories
assert.Len(t, list.Items, 3, "should return all three repositories")
names := make([]string, len(list.Items))
for i, item := range list.Items {
names[i] = item.GetName()
}
assert.Contains(t, names, "repo-with-connection")
assert.Contains(t, names, "repo-without-connection")
assert.Contains(t, names, "repo-with-different-connection")
})
}
func verifyToken(t *testing.T, appID, publicKey, token string) (bool, error) {
t.Helper()
// Parse the private key
key, err := jwt.ParseRSAPublicKeyFromPEM([]byte(publicKey))
if err != nil {
return false, err
}
parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (any, error) {
return key, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}))
if err != nil {
return false, err
}
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok || !parsedToken.Valid {
return false, fmt.Errorf("invalid token")
}
return claims.VerifyIssuer(appID, true), nil
}