diff --git a/.mockery.yaml b/.mockery.yaml index 5e2fe1637..b23943c35 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -26,3 +26,4 @@ packages: CACertificatesSDK: CertificatesSDK: KeysSDK: + KeySetsSDK: diff --git a/CHANGELOG.md b/CHANGELOG.md index a2ce5305c..dac2a1229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,8 @@ [#597](https://github.com/Kong/gateway-operator/pull/597) - Add `KongKey` reconciler for Konnect Keys. [#646](https://github.com/Kong/gateway-operator/pull/646) +- Add `KongKeySet` reconciler for Konnect KeySets. + [#657](https://github.com/Kong/gateway-operator/pull/657) - The `DataPlaneKonnectExtension` CRD has been introduced. Such a CRD can be attached to a `DataPlane` via the extensions field to have a konnect-flavored `DataPlane`. [#453](https://github.com/Kong/gateway-operator/pull/453), [#578](https://github.com/Kong/gateway-operator/pull/578) diff --git a/config/samples/konnect_kongkeyset.yaml b/config/samples/konnect_kongkeyset.yaml new file mode 100644 index 000000000..8ca4bc0b6 --- /dev/null +++ b/config/samples/konnect_kongkeyset.yaml @@ -0,0 +1,39 @@ +kind: KonnectAPIAuthConfiguration +apiVersion: konnect.konghq.com/v1alpha1 +metadata: + name: konnect-api-auth-dev-1 + namespace: default +spec: + type: token + token: kpat_XXXXXXXXXXXXXXXXXXX + serverURL: us.api.konghq.tech +--- +kind: KonnectGatewayControlPlane +apiVersion: konnect.konghq.com/v1alpha1 +metadata: + name: test1 + namespace: default +spec: + name: test1 + labels: + app: test1 + key1: test1 + konnect: + authRef: + name: konnect-api-auth-dev-1 +--- +kind: KongKeySet +apiVersion: configuration.konghq.com/v1alpha1 +metadata: + name: key-set-1 + namespace: default + annotations: + konghq.com/tags: "infra" +spec: + controlPlaneRef: + type: konnectNamespacedRef + konnectNamespacedRef: + name: test1 + name: key-set-1 + tags: + - production diff --git a/controller/konnect/constraints/constraints.go b/controller/konnect/constraints/constraints.go index 2b3c5bee9..41ea3f2df 100644 --- a/controller/konnect/constraints/constraints.go +++ b/controller/konnect/constraints/constraints.go @@ -26,7 +26,8 @@ type SupportedKonnectEntityType interface { configurationv1alpha1.KongCertificate | configurationv1alpha1.KongTarget | configurationv1alpha1.KongVault | - configurationv1alpha1.KongKey + configurationv1alpha1.KongKey | + configurationv1alpha1.KongKeySet // TODO: add other types GetTypeName() string diff --git a/controller/konnect/ops/kongkeyset.go b/controller/konnect/ops/kongkeyset.go new file mode 100644 index 000000000..0d3f16381 --- /dev/null +++ b/controller/konnect/ops/kongkeyset.go @@ -0,0 +1,15 @@ +package ops + +import ( + "context" + + sdkkonnectcomp "github.com/Kong/sdk-konnect-go/models/components" + sdkkonnectops "github.com/Kong/sdk-konnect-go/models/operations" +) + +// KeySetsSDK is the interface for the KeySetsSDK. +type KeySetsSDK interface { + CreateKeySet(ctx context.Context, controlPlaneID string, keySet sdkkonnectcomp.KeySetInput, opts ...sdkkonnectops.Option) (*sdkkonnectops.CreateKeySetResponse, error) + UpsertKeySet(ctx context.Context, request sdkkonnectops.UpsertKeySetRequest, opts ...sdkkonnectops.Option) (*sdkkonnectops.UpsertKeySetResponse, error) + DeleteKeySet(ctx context.Context, controlPlaneID string, keySetID string, opts ...sdkkonnectops.Option) (*sdkkonnectops.DeleteKeySetResponse, error) +} diff --git a/controller/konnect/ops/kongkeyset_mock.go b/controller/konnect/ops/kongkeyset_mock.go new file mode 100644 index 000000000..636462bea --- /dev/null +++ b/controller/konnect/ops/kongkeyset_mock.go @@ -0,0 +1,264 @@ +// Code generated by mockery. DO NOT EDIT. + +package ops + +import ( + context "context" + + components "github.com/Kong/sdk-konnect-go/models/components" + + mock "github.com/stretchr/testify/mock" + + operations "github.com/Kong/sdk-konnect-go/models/operations" +) + +// MockKeySetsSDK is an autogenerated mock type for the KeySetsSDK type +type MockKeySetsSDK struct { + mock.Mock +} + +type MockKeySetsSDK_Expecter struct { + mock *mock.Mock +} + +func (_m *MockKeySetsSDK) EXPECT() *MockKeySetsSDK_Expecter { + return &MockKeySetsSDK_Expecter{mock: &_m.Mock} +} + +// CreateKeySet provides a mock function with given fields: ctx, controlPlaneID, keySet, opts +func (_m *MockKeySetsSDK) CreateKeySet(ctx context.Context, controlPlaneID string, keySet components.KeySetInput, opts ...operations.Option) (*operations.CreateKeySetResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, controlPlaneID, keySet) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for CreateKeySet") + } + + var r0 *operations.CreateKeySetResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, components.KeySetInput, ...operations.Option) (*operations.CreateKeySetResponse, error)); ok { + return rf(ctx, controlPlaneID, keySet, opts...) + } + if rf, ok := ret.Get(0).(func(context.Context, string, components.KeySetInput, ...operations.Option) *operations.CreateKeySetResponse); ok { + r0 = rf(ctx, controlPlaneID, keySet, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*operations.CreateKeySetResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, components.KeySetInput, ...operations.Option) error); ok { + r1 = rf(ctx, controlPlaneID, keySet, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockKeySetsSDK_CreateKeySet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateKeySet' +type MockKeySetsSDK_CreateKeySet_Call struct { + *mock.Call +} + +// CreateKeySet is a helper method to define mock.On call +// - ctx context.Context +// - controlPlaneID string +// - keySet components.KeySetInput +// - opts ...operations.Option +func (_e *MockKeySetsSDK_Expecter) CreateKeySet(ctx interface{}, controlPlaneID interface{}, keySet interface{}, opts ...interface{}) *MockKeySetsSDK_CreateKeySet_Call { + return &MockKeySetsSDK_CreateKeySet_Call{Call: _e.mock.On("CreateKeySet", + append([]interface{}{ctx, controlPlaneID, keySet}, opts...)...)} +} + +func (_c *MockKeySetsSDK_CreateKeySet_Call) Run(run func(ctx context.Context, controlPlaneID string, keySet components.KeySetInput, opts ...operations.Option)) *MockKeySetsSDK_CreateKeySet_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]operations.Option, len(args)-3) + for i, a := range args[3:] { + if a != nil { + variadicArgs[i] = a.(operations.Option) + } + } + run(args[0].(context.Context), args[1].(string), args[2].(components.KeySetInput), variadicArgs...) + }) + return _c +} + +func (_c *MockKeySetsSDK_CreateKeySet_Call) Return(_a0 *operations.CreateKeySetResponse, _a1 error) *MockKeySetsSDK_CreateKeySet_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockKeySetsSDK_CreateKeySet_Call) RunAndReturn(run func(context.Context, string, components.KeySetInput, ...operations.Option) (*operations.CreateKeySetResponse, error)) *MockKeySetsSDK_CreateKeySet_Call { + _c.Call.Return(run) + return _c +} + +// DeleteKeySet provides a mock function with given fields: ctx, controlPlaneID, keySetID, opts +func (_m *MockKeySetsSDK) DeleteKeySet(ctx context.Context, controlPlaneID string, keySetID string, opts ...operations.Option) (*operations.DeleteKeySetResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, controlPlaneID, keySetID) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for DeleteKeySet") + } + + var r0 *operations.DeleteKeySetResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, ...operations.Option) (*operations.DeleteKeySetResponse, error)); ok { + return rf(ctx, controlPlaneID, keySetID, opts...) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, ...operations.Option) *operations.DeleteKeySetResponse); ok { + r0 = rf(ctx, controlPlaneID, keySetID, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*operations.DeleteKeySetResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, ...operations.Option) error); ok { + r1 = rf(ctx, controlPlaneID, keySetID, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockKeySetsSDK_DeleteKeySet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteKeySet' +type MockKeySetsSDK_DeleteKeySet_Call struct { + *mock.Call +} + +// DeleteKeySet is a helper method to define mock.On call +// - ctx context.Context +// - controlPlaneID string +// - keySetID string +// - opts ...operations.Option +func (_e *MockKeySetsSDK_Expecter) DeleteKeySet(ctx interface{}, controlPlaneID interface{}, keySetID interface{}, opts ...interface{}) *MockKeySetsSDK_DeleteKeySet_Call { + return &MockKeySetsSDK_DeleteKeySet_Call{Call: _e.mock.On("DeleteKeySet", + append([]interface{}{ctx, controlPlaneID, keySetID}, opts...)...)} +} + +func (_c *MockKeySetsSDK_DeleteKeySet_Call) Run(run func(ctx context.Context, controlPlaneID string, keySetID string, opts ...operations.Option)) *MockKeySetsSDK_DeleteKeySet_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]operations.Option, len(args)-3) + for i, a := range args[3:] { + if a != nil { + variadicArgs[i] = a.(operations.Option) + } + } + run(args[0].(context.Context), args[1].(string), args[2].(string), variadicArgs...) + }) + return _c +} + +func (_c *MockKeySetsSDK_DeleteKeySet_Call) Return(_a0 *operations.DeleteKeySetResponse, _a1 error) *MockKeySetsSDK_DeleteKeySet_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockKeySetsSDK_DeleteKeySet_Call) RunAndReturn(run func(context.Context, string, string, ...operations.Option) (*operations.DeleteKeySetResponse, error)) *MockKeySetsSDK_DeleteKeySet_Call { + _c.Call.Return(run) + return _c +} + +// UpsertKeySet provides a mock function with given fields: ctx, request, opts +func (_m *MockKeySetsSDK) UpsertKeySet(ctx context.Context, request operations.UpsertKeySetRequest, opts ...operations.Option) (*operations.UpsertKeySetResponse, error) { + _va := make([]interface{}, len(opts)) + for _i := range opts { + _va[_i] = opts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, request) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for UpsertKeySet") + } + + var r0 *operations.UpsertKeySetResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, operations.UpsertKeySetRequest, ...operations.Option) (*operations.UpsertKeySetResponse, error)); ok { + return rf(ctx, request, opts...) + } + if rf, ok := ret.Get(0).(func(context.Context, operations.UpsertKeySetRequest, ...operations.Option) *operations.UpsertKeySetResponse); ok { + r0 = rf(ctx, request, opts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*operations.UpsertKeySetResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, operations.UpsertKeySetRequest, ...operations.Option) error); ok { + r1 = rf(ctx, request, opts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockKeySetsSDK_UpsertKeySet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertKeySet' +type MockKeySetsSDK_UpsertKeySet_Call struct { + *mock.Call +} + +// UpsertKeySet is a helper method to define mock.On call +// - ctx context.Context +// - request operations.UpsertKeySetRequest +// - opts ...operations.Option +func (_e *MockKeySetsSDK_Expecter) UpsertKeySet(ctx interface{}, request interface{}, opts ...interface{}) *MockKeySetsSDK_UpsertKeySet_Call { + return &MockKeySetsSDK_UpsertKeySet_Call{Call: _e.mock.On("UpsertKeySet", + append([]interface{}{ctx, request}, opts...)...)} +} + +func (_c *MockKeySetsSDK_UpsertKeySet_Call) Run(run func(ctx context.Context, request operations.UpsertKeySetRequest, opts ...operations.Option)) *MockKeySetsSDK_UpsertKeySet_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]operations.Option, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(operations.Option) + } + } + run(args[0].(context.Context), args[1].(operations.UpsertKeySetRequest), variadicArgs...) + }) + return _c +} + +func (_c *MockKeySetsSDK_UpsertKeySet_Call) Return(_a0 *operations.UpsertKeySetResponse, _a1 error) *MockKeySetsSDK_UpsertKeySet_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockKeySetsSDK_UpsertKeySet_Call) RunAndReturn(run func(context.Context, operations.UpsertKeySetRequest, ...operations.Option) (*operations.UpsertKeySetResponse, error)) *MockKeySetsSDK_UpsertKeySet_Call { + _c.Call.Return(run) + return _c +} + +// NewMockKeySetsSDK creates a new instance of MockKeySetsSDK. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockKeySetsSDK(t interface { + mock.TestingT + Cleanup(func()) +}) *MockKeySetsSDK { + mock := &MockKeySetsSDK{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/controller/konnect/ops/ops.go b/controller/konnect/ops/ops.go index a25299670..a9ac4cda2 100644 --- a/controller/konnect/ops/ops.go +++ b/controller/konnect/ops/ops.go @@ -80,7 +80,8 @@ func Create[ return e, createVault(ctx, sdk.GetVaultSDK(), ent) case *configurationv1alpha1.KongKey: return e, createKey(ctx, sdk.GetKeysSDK(), ent) - + case *configurationv1alpha1.KongKeySet: + return e, createKeySet(ctx, sdk.GetKeySetsSDK(), ent) // --------------------------------------------------------------------- // TODO: add other Konnect types @@ -134,7 +135,8 @@ func Delete[ return deleteVault(ctx, sdk.GetVaultSDK(), ent) case *configurationv1alpha1.KongKey: return deleteKey(ctx, sdk.GetKeysSDK(), ent) - + case *configurationv1alpha1.KongKeySet: + return deleteKeySet(ctx, sdk.GetKeySetsSDK(), ent) // --------------------------------------------------------------------- // TODO: add other Konnect types @@ -233,6 +235,8 @@ func Update[ return ctrl.Result{}, updateVault(ctx, sdk.GetVaultSDK(), ent) case *configurationv1alpha1.KongKey: return ctrl.Result{}, updateKey(ctx, sdk.GetKeysSDK(), ent) + case *configurationv1alpha1.KongKeySet: + return ctrl.Result{}, updateKeySet(ctx, sdk.GetKeySetsSDK(), ent) // --------------------------------------------------------------------- // TODO: add other Konnect types diff --git a/controller/konnect/ops/ops_kongkeyset.go b/controller/konnect/ops/ops_kongkeyset.go new file mode 100644 index 000000000..86f9c7f5a --- /dev/null +++ b/controller/konnect/ops/ops_kongkeyset.go @@ -0,0 +1,146 @@ +package ops + +import ( + "context" + "errors" + "fmt" + "slices" + + sdkkonnectcomp "github.com/Kong/sdk-konnect-go/models/components" + sdkkonnectops "github.com/Kong/sdk-konnect-go/models/operations" + sdkkonnecterrs "github.com/Kong/sdk-konnect-go/models/sdkerrors" + "github.com/samber/lo" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + + configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" + "github.com/kong/kubernetes-configuration/pkg/metadata" +) + +// createKeySet creates a KongKeySet in Konnect. +// It sets the KonnectID and the Programmed condition in the KongKeySet status. +func createKeySet( + ctx context.Context, + sdk KeySetsSDK, + keySet *configurationv1alpha1.KongKeySet, +) error { + cpID := keySet.GetControlPlaneID() + if cpID == "" { + return fmt.Errorf("can't create %T %s without a Konnect ControlPlane ID", keySet, client.ObjectKeyFromObject(keySet)) + } + + resp, err := sdk.CreateKeySet(ctx, + cpID, + kongKeySetToKeySetInput(keySet), + ) + + // TODO: handle already exists + // Can't adopt it as it will cause conflicts between the controller + // that created that entity and already manages it, hm + if errWrap := wrapErrIfKonnectOpFailed(err, CreateOp, keySet); errWrap != nil { + SetKonnectEntityProgrammedConditionFalse(keySet, "FailedToCreate", errWrap.Error()) + return errWrap + } + + keySet.Status.Konnect.SetKonnectID(*resp.KeySet.ID) + SetKonnectEntityProgrammedCondition(keySet) + + return nil +} + +// updateKeySet updates a KongKeySet in Konnect. +// The KongKeySet must have a KonnectID set in its status. +// It returns an error if the KongKeySet does not have a KonnectID. +func updateKeySet( + ctx context.Context, + sdk KeySetsSDK, + keySet *configurationv1alpha1.KongKeySet, +) error { + cpID := keySet.GetControlPlaneID() + if cpID == "" { + return fmt.Errorf("can't update %T without a ControlPlaneID", keySet) + } + + _, err := sdk.UpsertKeySet(ctx, + sdkkonnectops.UpsertKeySetRequest{ + ControlPlaneID: cpID, + KeySetID: keySet.GetKonnectStatus().GetKonnectID(), + KeySet: kongKeySetToKeySetInput(keySet), + }, + ) + + // TODO: handle already exists + // Can't adopt it as it will cause conflicts between the controller + // that created that entity and already manages it, hm + if errWrap := wrapErrIfKonnectOpFailed(err, UpdateOp, keySet); errWrap != nil { + var sdkError *sdkkonnecterrs.SDKError + if errors.As(errWrap, &sdkError) { + if sdkError.StatusCode == 404 { + if err := createKeySet(ctx, sdk, keySet); err != nil { + return FailedKonnectOpError[configurationv1alpha1.KongKeySet]{ + Op: UpdateOp, + Err: err, + } + } + return nil // createKeySet sets the status so we can return here. + } + return FailedKonnectOpError[configurationv1alpha1.KongKeySet]{ + Op: UpdateOp, + Err: sdkError, + } + } + SetKonnectEntityProgrammedConditionFalse(keySet, "FailedToUpdate", errWrap.Error()) + return errWrap + } + + SetKonnectEntityProgrammedCondition(keySet) + + return nil +} + +// deleteKeySet deletes a KongKeySet in Konnect. +// The KongKeySet must have a KonnectID set in its status. +// It returns an error if the operation fails. +func deleteKeySet( + ctx context.Context, + sdk KeySetsSDK, + keySet *configurationv1alpha1.KongKeySet, +) error { + id := keySet.Status.Konnect.GetKonnectID() + _, err := sdk.DeleteKeySet(ctx, keySet.GetControlPlaneID(), id) + if errWrap := wrapErrIfKonnectOpFailed(err, DeleteOp, keySet); errWrap != nil { + var sdkError *sdkkonnecterrs.SDKError + if errors.As(errWrap, &sdkError) { + if sdkError.StatusCode == 404 { + ctrllog.FromContext(ctx). + Info("entity not found in Konnect, skipping delete", + "op", DeleteOp, "type", keySet.GetTypeName(), "id", id, + ) + return nil + } + return FailedKonnectOpError[configurationv1alpha1.KongKeySet]{ + Op: DeleteOp, + Err: sdkError, + } + } + return FailedKonnectOpError[configurationv1alpha1.KongKeySet]{ + Op: DeleteOp, + Err: errWrap, + } + } + + return nil +} + +func kongKeySetToKeySetInput(keySet *configurationv1alpha1.KongKeySet) sdkkonnectcomp.KeySetInput { + var ( + annotationTags = metadata.ExtractTags(keySet) + specTags = keySet.Spec.Tags + k8sMetaTags = GenerateKubernetesMetadataTags(keySet) + ) + return sdkkonnectcomp.KeySetInput{ + Name: lo.ToPtr(keySet.Spec.Name), + // Deduplicate tags to avoid rejection by Konnect. + Tags: lo.Uniq(slices.Concat(annotationTags, specTags, k8sMetaTags)), + } +} diff --git a/controller/konnect/ops/ops_kongkeyset_test.go b/controller/konnect/ops/ops_kongkeyset_test.go new file mode 100644 index 000000000..a8984dece --- /dev/null +++ b/controller/konnect/ops/ops_kongkeyset_test.go @@ -0,0 +1,55 @@ +package ops + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" + + konnectconsts "github.com/kong/gateway-operator/controller/konnect/consts" + + configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" +) + +func TestKongKeySetToKeySetInput(t *testing.T) { + keySet := &configurationv1alpha1.KongKeySet{ + TypeMeta: metav1.TypeMeta{ + Kind: "KongKeySet", + APIVersion: "configuration.konghq.com/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "keySet-1", + Namespace: "default", + Generation: 2, + UID: k8stypes.UID(uuid.NewString()), + Annotations: map[string]string{ + konnectconsts.AnnotationTags: "tag1,tag2,duplicate", + }, + }, + Spec: configurationv1alpha1.KongKeySetSpec{ + KongKeySetAPISpec: configurationv1alpha1.KongKeySetAPISpec{ + Name: "name", + Tags: []string{"tag3", "tag4", "duplicate"}, + }, + }, + } + output := kongKeySetToKeySetInput(keySet) + expectedTags := []string{ + "k8s-generation:2", + "k8s-kind:KongKeySet", + "k8s-name:keySet-1", + "k8s-uid:" + string(keySet.GetUID()), + "k8s-version:v1alpha1", + "k8s-group:configuration.konghq.com", + "k8s-namespace:default", + "tag1", + "tag2", + "tag3", + "tag4", + "duplicate", + } + require.ElementsMatch(t, expectedTags, output.Tags) + require.Equal(t, "name", *output.Name) +} diff --git a/controller/konnect/ops/sdkfactory.go b/controller/konnect/ops/sdkfactory.go index 51bca3871..0c2102e1b 100644 --- a/controller/konnect/ops/sdkfactory.go +++ b/controller/konnect/ops/sdkfactory.go @@ -22,6 +22,7 @@ type SDKWrapper interface { GetCACertificatesSDK() CACertificatesSDK GetCertificatesSDK() CertificatesSDK GetKeysSDK() KeysSDK + GetKeySetsSDK() KeySetsSDK } type sdkWrapper struct { @@ -105,6 +106,11 @@ func (w sdkWrapper) GetKeysSDK() KeysSDK { return w.sdk.Keys } +// GetKeySetsSDK returns the SDK to operate key sets. +func (w sdkWrapper) GetKeySetsSDK() KeySetsSDK { + return w.sdk.KeySets +} + // SDKToken is a token used to authenticate with the Konnect SDK. type SDKToken string diff --git a/controller/konnect/ops/sdkfactory_mock.go b/controller/konnect/ops/sdkfactory_mock.go index a1e8ba895..2d05e8290 100644 --- a/controller/konnect/ops/sdkfactory_mock.go +++ b/controller/konnect/ops/sdkfactory_mock.go @@ -22,6 +22,7 @@ type MockSDKWrapper struct { CertificatesSDK *MockCertificatesSDK VaultSDK *MockVaultSDK KeysSDK *MockKeysSDK + KeySetsSDK *MockKeySetsSDK } var _ SDKWrapper = MockSDKWrapper{} @@ -43,6 +44,7 @@ func NewMockSDKWrapperWithT(t *testing.T) *MockSDKWrapper { CertificatesSDK: NewMockCertificatesSDK(t), VaultSDK: NewMockVaultSDK(t), KeysSDK: NewMockKeysSDK(t), + KeySetsSDK: NewMockKeySetsSDK(t), } } @@ -106,6 +108,10 @@ func (m MockSDKWrapper) GetKeysSDK() KeysSDK { return m.KeysSDK } +func (m MockSDKWrapper) GetKeySetsSDK() KeySetsSDK { + return m.KeySetsSDK +} + type MockSDKFactory struct { t *testing.T SDK *MockSDKWrapper diff --git a/controller/konnect/reconciler_generic.go b/controller/konnect/reconciler_generic.go index dd58b7942..6dc6e3623 100644 --- a/controller/konnect/reconciler_generic.go +++ b/controller/konnect/reconciler_generic.go @@ -1038,6 +1038,11 @@ func getControlPlaneRef[T constraints.SupportedKonnectEntityType, TEnt constrain return mo.None[configurationv1alpha1.ControlPlaneRef]() } return mo.Some(*e.Spec.ControlPlaneRef) + case *configurationv1alpha1.KongKeySet: + if e.Spec.ControlPlaneRef == nil { + return mo.None[configurationv1alpha1.ControlPlaneRef]() + } + return mo.Some(*e.Spec.ControlPlaneRef) default: return mo.None[configurationv1alpha1.ControlPlaneRef]() } diff --git a/controller/konnect/watch.go b/controller/konnect/watch.go index d27f2df30..4529a4aa9 100644 --- a/controller/konnect/watch.go +++ b/controller/konnect/watch.go @@ -56,6 +56,8 @@ func ReconciliationWatchOptionsForEntity[ return KongVaultReconciliationWatchOptions(cl) case *configurationv1alpha1.KongKey: return KongKeyReconciliationWatchOptions(cl) + case *configurationv1alpha1.KongKeySet: + return KongKeySetReconciliationWatchOptions(cl) default: panic(fmt.Sprintf("unsupported entity type %T", ent)) } diff --git a/controller/konnect/watch_kongkeyset.go b/controller/konnect/watch_kongkeyset.go new file mode 100644 index 000000000..9bbede0a1 --- /dev/null +++ b/controller/konnect/watch_kongkeyset.go @@ -0,0 +1,194 @@ +package konnect + +import ( + "context" + "fmt" + "reflect" + + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + operatorerrors "github.com/kong/gateway-operator/internal/errors" + "github.com/kong/gateway-operator/modules/manager/logging" + + configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" + konnectv1alpha1 "github.com/kong/kubernetes-configuration/api/konnect/v1alpha1" +) + +// KongKeySetReconciliationWatchOptions returns the watch options for the KongKeySet. +func KongKeySetReconciliationWatchOptions(cl client.Client) []func(*ctrl.Builder) *ctrl.Builder { + return []func(*ctrl.Builder) *ctrl.Builder{ + func(b *ctrl.Builder) *ctrl.Builder { + return b.For(&configurationv1alpha1.KongKeySet{}, + builder.WithPredicates( + predicate.NewPredicateFuncs(kongKeySetRefersToKonnectControlPlane), + ), + ) + }, + func(b *ctrl.Builder) *ctrl.Builder { + return b.Watches( + &konnectv1alpha1.KonnectAPIAuthConfiguration{}, + handler.EnqueueRequestsFromMapFunc( + enqueueKongKeySetForKonnectAPIAuthConfiguration(cl), + ), + ) + }, + func(b *ctrl.Builder) *ctrl.Builder { + return b.Watches( + &konnectv1alpha1.KonnectGatewayControlPlane{}, + handler.EnqueueRequestsFromMapFunc( + enqueueKongKeySetForKonnectControlPlane(cl), + ), + ) + }, + } +} + +func kongKeySetRefersToKonnectControlPlane(obj client.Object) bool { + kongKeySet, ok := obj.(*configurationv1alpha1.KongKeySet) + if !ok { + ctrllog.FromContext(context.Background()).Error( + operatorerrors.ErrUnexpectedObject, + "failed to run predicate function", + "expected", "KongKeySet", "found", reflect.TypeOf(obj), + ) + return false + } + return kongKeySet.Spec.ControlPlaneRef != nil && + kongKeySet.Spec.ControlPlaneRef.Type == configurationv1alpha1.ControlPlaneRefKonnectNamespacedRef +} + +func enqueueKongKeySetForKonnectAPIAuthConfiguration(cl client.Client) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + auth, ok := obj.(*konnectv1alpha1.KonnectAPIAuthConfiguration) + if !ok { + return nil + } + var l configurationv1alpha1.KongKeySetList + if err := cl.List(ctx, &l, &client.ListOptions{ + // TODO: change this when cross namespace refs are allowed. + Namespace: auth.GetNamespace(), + }); err != nil { + return nil + } + + var ret []reconcile.Request + for _, keySet := range l.Items { + cpRef, ok := getControlPlaneRef(&keySet).Get() + if !ok { + continue + } + + switch cpRef.Type { + case configurationv1alpha1.ControlPlaneRefKonnectNamespacedRef: + nn := types.NamespacedName{ + Name: cpRef.KonnectNamespacedRef.Name, + Namespace: keySet.Namespace, + } + // TODO: change this when cross namespace refs are allowed. + if nn.Namespace != auth.Namespace { + continue + } + var cp konnectv1alpha1.KonnectGatewayControlPlane + if err := cl.Get(ctx, nn, &cp); err != nil { + ctrllog.FromContext(ctx).Error( + err, + "failed to get KonnectControlPlane", + "KonnectControlPlane", nn, + ) + continue + } + + // TODO: change this when cross namespace refs are allowed. + if cp.GetKonnectAPIAuthConfigurationRef().Name != auth.Name { + continue + } + + ret = append(ret, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: keySet.Namespace, + Name: keySet.Name, + }, + }) + + case configurationv1alpha1.ControlPlaneRefKonnectID: + ctrllog.FromContext(ctx).Error( + fmt.Errorf("unimplemented ControlPlaneRef type %q", cpRef.Type), + "unimplemented ControlPlaneRef for KongKeySet", + "KongKeySet", keySet, "refType", cpRef.Type, + ) + continue + + default: + ctrllog.FromContext(ctx).V(logging.DebugLevel.Value()).Info( + "unsupported ControlPlaneRef for KongKeySet", + "KongKeySet", keySet, "refType", cpRef.Type, + ) + continue + } + } + return ret + } +} + +func enqueueKongKeySetForKonnectControlPlane( + cl client.Client, +) func(ctx context.Context, obj client.Object) []reconcile.Request { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + cp, ok := obj.(*konnectv1alpha1.KonnectGatewayControlPlane) + if !ok { + return nil + } + var l configurationv1alpha1.KongKeySetList + if err := cl.List(ctx, &l, &client.ListOptions{ + // TODO: change this when cross namespace refs are allowed. + Namespace: cp.GetNamespace(), + }); err != nil { + return nil + } + + var ret []reconcile.Request + for _, keySet := range l.Items { + cpRef, ok := getControlPlaneRef(&keySet).Get() + if !ok { + continue + } + switch cpRef.Type { + case configurationv1alpha1.ControlPlaneRefKonnectNamespacedRef: + // TODO: change this when cross namespace refs are allowed. + if cpRef.KonnectNamespacedRef.Name != cp.Name { + continue + } + + ret = append(ret, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: keySet.Namespace, + Name: keySet.Name, + }, + }) + + case configurationv1alpha1.ControlPlaneRefKonnectID: + ctrllog.FromContext(ctx).Error( + fmt.Errorf("unimplemented ControlPlaneRef type %q", cpRef.Type), + "unimplemented ControlPlaneRef for KongKeySet", + "KongKeySet", keySet, "refType", cpRef.Type, + ) + continue + + default: + ctrllog.FromContext(ctx).V(logging.DebugLevel.Value()).Info( + "unsupported ControlPlaneRef for KongKeySet", + "KongKeySet", keySet, "refType", cpRef.Type, + ) + continue + } + } + return ret + } +} diff --git a/controller/konnect/watch_test.go b/controller/konnect/watch_test.go index 355f2ce9f..bb2f096ba 100644 --- a/controller/konnect/watch_test.go +++ b/controller/konnect/watch_test.go @@ -21,6 +21,7 @@ func TestWatchOptions(t *testing.T) { testReconciliationWatchOptionsForEntity(t, &configurationv1alpha1.KongCACertificate{}) testReconciliationWatchOptionsForEntity(t, &configurationv1alpha1.KongCertificate{}) testReconciliationWatchOptionsForEntity(t, &configurationv1alpha1.KongKey{}) + testReconciliationWatchOptionsForEntity(t, &configurationv1alpha1.KongKeySet{}) } func testReconciliationWatchOptionsForEntity[ diff --git a/go.mod b/go.mod index b6b3c9822..8e04ace27 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/go-logr/logr v1.4.2 github.com/google/go-containerregistry v0.20.2 github.com/google/uuid v1.6.0 - github.com/kong/kubernetes-configuration v0.0.20 + github.com/kong/kubernetes-configuration v0.0.21 github.com/kong/kubernetes-telemetry v0.1.5 github.com/kong/kubernetes-testing-framework v0.47.2 github.com/kong/semver/v4 v4.0.1 diff --git a/go.sum b/go.sum index de1fed1c8..9edac1cf7 100644 --- a/go.sum +++ b/go.sum @@ -224,8 +224,8 @@ github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kong/go-kong v0.59.1 h1:AJZtyCD+Zyqe/mF/m+x3/qN/GPVxAH7jq9zGJTHRfjc= github.com/kong/go-kong v0.59.1/go.mod h1:8Vt6HmtgLNgL/7bSwAlz3DIWqBtzG7qEt9+OnMiQOa0= -github.com/kong/kubernetes-configuration v0.0.20 h1:lUjvaP9sc0bDdToVD6hf5aHyDirxtHBNYENwjo3TUZg= -github.com/kong/kubernetes-configuration v0.0.20/go.mod h1:DXrWtdZzewUyPZBR4zvDoY/B8rHxeqcqCBDbyHA+B0Q= +github.com/kong/kubernetes-configuration v0.0.21 h1:ezrHocQbP/gtV+79TVQWaUSnt6Gd3u7kzaspoTWefQ4= +github.com/kong/kubernetes-configuration v0.0.21/go.mod h1:DXrWtdZzewUyPZBR4zvDoY/B8rHxeqcqCBDbyHA+B0Q= github.com/kong/kubernetes-telemetry v0.1.5 h1:xHwU1q0IvfEYqpj03po73ZKbVarnFPUwzkoFkdVnr9w= github.com/kong/kubernetes-telemetry v0.1.5/go.mod h1:1UXyZ6N3e8Fl6YguToQ6tKNveonkhjSqxzY7HVW+Ba4= github.com/kong/kubernetes-testing-framework v0.47.2 h1:+2Z9anTpbV/hwNeN+NFQz53BMU+g3QJydkweBp3tULo= diff --git a/modules/manager/controller_setup.go b/modules/manager/controller_setup.go index 0b4c14766..4cc079f24 100644 --- a/modules/manager/controller_setup.go +++ b/modules/manager/controller_setup.go @@ -96,6 +96,8 @@ const ( KongVaultControllerName = "KongVault" // KongKeyControllerName is the name of KongKey controller. KongKeyControllerName = "KongKey" + // KongKeySetControllerName is the name of KongKeySet controller. + KongKeySetControllerName = "KongKeySet" ) // SetupControllersShim runs SetupControllers and returns its result as a slice of the map values. @@ -465,6 +467,15 @@ func SetupControllers(mgr manager.Manager, c *Config) (map[string]ControllerDef, konnect.WithKonnectEntitySyncPeriod[configurationv1alpha1.KongKey](c.KonnectSyncPeriod), ), }, + KongKeySetControllerName: { + Enabled: c.KonnectControllersEnabled, + Controller: konnect.NewKonnectEntityReconciler( + sdkFactory, + c.DevelopmentMode, + mgr.GetClient(), + konnect.WithKonnectEntitySyncPeriod[configurationv1alpha1.KongKeySet](c.KonnectSyncPeriod), + ), + }, KongPluginControllerName: { Enabled: c.KonnectControllersEnabled, Controller: konnect.NewKongPluginReconciler( diff --git a/test/envtest/deploy_resources.go b/test/envtest/deploy_resources.go index cc227e151..2676cf076 100644 --- a/test/envtest/deploy_resources.go +++ b/test/envtest/deploy_resources.go @@ -504,3 +504,35 @@ func deployProxyCachePlugin( t.Logf("deployed new %s KongPlugin (%s)", client.ObjectKeyFromObject(plugin), plugin.PluginName) return plugin } + +// deployKongKeySetAttachedToCP deploys a KongKeySet resource attached to a CP and returns the resource. +func deployKongKeySetAttachedToCP( + t *testing.T, + ctx context.Context, + cl client.Client, + name string, + cp *konnectv1alpha1.KonnectGatewayControlPlane, +) *configurationv1alpha1.KongKeySet { + t.Helper() + + keySet := &configurationv1alpha1.KongKeySet{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "key-set-", + }, + Spec: configurationv1alpha1.KongKeySetSpec{ + ControlPlaneRef: &configurationv1alpha1.ControlPlaneRef{ + Type: configurationv1alpha1.ControlPlaneRefKonnectNamespacedRef, + KonnectNamespacedRef: &configurationv1alpha1.KonnectNamespacedRef{ + Name: cp.GetName(), + }, + }, + KongKeySetAPISpec: configurationv1alpha1.KongKeySetAPISpec{ + Name: name, + }, + }, + } + require.NoError(t, cl.Create(ctx, keySet)) + t.Logf("deployed new KongKeySet %s", client.ObjectKeyFromObject(keySet)) + + return keySet +} diff --git a/test/envtest/konnect_entities_keyset_test.go b/test/envtest/konnect_entities_keyset_test.go new file mode 100644 index 000000000..8e173941b --- /dev/null +++ b/test/envtest/konnect_entities_keyset_test.go @@ -0,0 +1,117 @@ +package envtest + +import ( + "context" + "testing" + + sdkkonnectcomp "github.com/Kong/sdk-konnect-go/models/components" + sdkkonnectops "github.com/Kong/sdk-konnect-go/models/operations" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kong/gateway-operator/controller/konnect" + "github.com/kong/gateway-operator/controller/konnect/conditions" + "github.com/kong/gateway-operator/controller/konnect/ops" + "github.com/kong/gateway-operator/modules/manager/scheme" + + configurationv1alpha1 "github.com/kong/kubernetes-configuration/api/configuration/v1alpha1" +) + +func TestKongKeySet(t *testing.T) { + const ( + keySetName = "key-set-name" + keySetID = "key-set-id" + ) + + t.Parallel() + ctx, cancel := Context(t, context.Background()) + defer cancel() + cfg, ns := Setup(t, ctx, scheme.Get()) + + t.Log("Setting up the manager with reconcilers") + mgr, logs := NewManager(t, ctx, cfg, scheme.Get()) + factory := ops.NewMockSDKFactory(t) + sdk := factory.SDK + StartReconcilers(ctx, t, mgr, logs, + konnect.NewKonnectEntityReconciler(factory, false, mgr.GetClient(), + konnect.WithKonnectEntitySyncPeriod[configurationv1alpha1.KongKeySet](konnectInfiniteSyncTime), + ), + ) + + t.Log("Setting up clients") + cl, err := client.NewWithWatch(mgr.GetConfig(), client.Options{ + Scheme: scheme.Get(), + }) + require.NoError(t, err) + clientNamespaced := client.NewNamespacedClient(mgr.GetClient(), ns.Name) + + t.Log("Creating KonnectAPIAuthConfiguration and KonnectGatewayControlPlane") + apiAuth := deployKonnectAPIAuthConfigurationWithProgrammed(t, ctx, clientNamespaced) + cp := deployKonnectGatewayControlPlaneWithID(t, ctx, clientNamespaced, apiAuth) + + t.Log("Setting up SDK expectations on KongKeySet creation") + sdk.KeySetsSDK.EXPECT().CreateKeySet(mock.Anything, cp.GetKonnectStatus().GetKonnectID(), + mock.MatchedBy(func(input sdkkonnectcomp.KeySetInput) bool { + return input.Name != nil && *input.Name == keySetName + }), + ).Return(&sdkkonnectops.CreateKeySetResponse{ + KeySet: &sdkkonnectcomp.KeySet{ + ID: lo.ToPtr(keySetID), + }, + }, nil) + + t.Log("Setting up a watch for KongKeySet events") + w := setupWatch[configurationv1alpha1.KongKeySetList](t, ctx, cl, client.InNamespace(ns.Name)) + + t.Log("Creating KongKeySet") + createdKeySet := deployKongKeySetAttachedToCP(t, ctx, clientNamespaced, keySetName, cp) + + t.Log("Waiting for KongKeySet to be programmed") + watchFor(t, ctx, w, watch.Modified, func(c *configurationv1alpha1.KongKeySet) bool { + if c.GetName() != createdKeySet.GetName() { + return false + } + return lo.ContainsBy(c.Status.Conditions, func(condition metav1.Condition) bool { + return condition.Type == conditions.KonnectEntityProgrammedConditionType && + condition.Status == metav1.ConditionTrue + }) + }, "KongKeySet's Programmed condition should be true eventually") + + t.Log("Waiting for KongKeySet to be created in the SDK") + require.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, factory.SDK.KeySetsSDK.AssertExpectations(t)) + }, waitTime, tickTime) + + t.Log("Setting up SDK expectations on KongKeySet update") + sdk.KeySetsSDK.EXPECT().UpsertKeySet(mock.Anything, mock.MatchedBy(func(r sdkkonnectops.UpsertKeySetRequest) bool { + return r.KeySetID == keySetID && + lo.Contains(r.KeySet.Tags, "addedTag") + })).Return(&sdkkonnectops.UpsertKeySetResponse{}, nil) + + t.Log("Patching KongKeySet") + certToPatch := createdKeySet.DeepCopy() + certToPatch.Spec.Tags = append(certToPatch.Spec.Tags, "addedTag") + require.NoError(t, clientNamespaced.Patch(ctx, certToPatch, client.MergeFrom(createdKeySet))) + + t.Log("Waiting for KongKeySet to be updated in the SDK") + assert.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, factory.SDK.KeySetsSDK.AssertExpectations(t)) + }, waitTime, tickTime) + + t.Log("Setting up SDK expectations on KongKeySet deletion") + sdk.KeySetsSDK.EXPECT().DeleteKeySet(mock.Anything, cp.GetKonnectStatus().GetKonnectID(), keySetID). + Return(&sdkkonnectops.DeleteKeySetResponse{}, nil) + + t.Log("Deleting KongKeySet") + require.NoError(t, cl.Delete(ctx, createdKeySet)) + + t.Log("Waiting for KongKeySet to be deleted in the SDK") + assert.EventuallyWithT(t, func(c *assert.CollectT) { + assert.True(c, factory.SDK.KeySetsSDK.AssertExpectations(t)) + }, waitTime, tickTime) +}