api + basic logic + hydra client crud + validation + happy path integration test

This commit is contained in:
Jakub Kabza
2019-08-21 12:10:25 +02:00
parent dfb5974746
commit cd78361bf7
20 changed files with 1484 additions and 169 deletions

View File

@ -17,28 +17,77 @@ package controllers
import (
"context"
"fmt"
"k8s.io/apimachinery/pkg/types"
"github.com/go-logr/logr"
hydrav1alpha1 "github.com/ory/hydra-maester/api/v1alpha1"
"github.com/ory/hydra-maester/hydra"
apiv1 "k8s.io/api/core/v1"
apierrs "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
hydrav1alpha1 "github.com/ory/hydra-maester/api/v1alpha1"
)
const (
clientIDKey = "client_id"
clientSecretKey = "client_secret"
)
type HydraClientInterface interface {
GetOAuth2Client(id string) (*hydra.OAuth2ClientJSON, bool, error)
PostOAuth2Client(o *hydra.OAuth2ClientJSON) (*hydra.OAuth2ClientJSON, error)
PutOAuth2Client(o *hydra.OAuth2ClientJSON) (*hydra.OAuth2ClientJSON, error)
DeleteOAuth2Client(id string) error
}
// OAuth2ClientReconciler reconciles a OAuth2Client object
type OAuth2ClientReconciler struct {
HydraClient HydraClientInterface
Log logr.Logger
client.Client
Log logr.Logger
}
// +kubebuilder:rbac:groups=hydra.ory.sh,resources=oauth2clients,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=hydra.ory.sh,resources=oauth2clients/status,verbs=get;update;patch
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
func (r *OAuth2ClientReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
_ = context.Background()
ctx := context.Background()
_ = r.Log.WithValues("oauth2client", req.NamespacedName)
// your logic here
var client hydrav1alpha1.OAuth2Client
if err := r.Get(ctx, req.NamespacedName, &client); err != nil {
if apierrs.IsNotFound(err) {
if err := r.unregisterOAuth2Client(ctx, req.NamespacedName); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
if client.Generation != client.Status.ObservedGeneration {
var registered = false
var err error
if client.Status.ClientID != nil {
_, registered, err = r.HydraClient.GetOAuth2Client(*client.Status.ClientID)
if err != nil {
return ctrl.Result{}, err
}
}
if !registered {
return ctrl.Result{}, r.registerOAuth2Client(ctx, &client)
}
return ctrl.Result{}, r.updateRegisteredOAuth2Client(&client)
}
return ctrl.Result{}, nil
}
@ -48,3 +97,49 @@ func (r *OAuth2ClientReconciler) SetupWithManager(mgr ctrl.Manager) error {
For(&hydrav1alpha1.OAuth2Client{}).
Complete(r)
}
func (r *OAuth2ClientReconciler) registerOAuth2Client(ctx context.Context, client *hydrav1alpha1.OAuth2Client) error {
created, err := r.HydraClient.PostOAuth2Client(client.ToOAuth2ClientJSON())
if err != nil {
return err
}
clientSecret := apiv1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: client.Name,
Namespace: client.Namespace,
},
Data: map[string][]byte{
clientSecretKey: []byte(*created.Secret),
clientIDKey: []byte(*created.ClientID),
},
}
err = r.Create(ctx, &clientSecret)
if err != nil {
return err
}
client.Status.Secret = &clientSecret.Name
client.Status.ClientID = created.ClientID
client.Status.ObservedGeneration = client.Generation
return r.Status().Update(ctx, client)
}
func (r *OAuth2ClientReconciler) unregisterOAuth2Client(ctx context.Context, namespacedName types.NamespacedName) error {
var sec apiv1.Secret
if err := r.Get(ctx, namespacedName, &sec); err != nil {
if apierrs.IsNotFound(err) {
r.Log.Info(fmt.Sprintf("unable to find secret corresponding with client %s/%s. Manual deletion recommended", namespacedName.Name, namespacedName.Namespace))
return nil
}
return err
}
return r.HydraClient.DeleteOAuth2Client(string(sec.Data[clientIDKey]))
}
func (r *OAuth2ClientReconciler) updateRegisteredOAuth2Client(client *hydrav1alpha1.OAuth2Client) error {
_, err := r.HydraClient.PutOAuth2Client(client.ToOAuth2ClientJSON())
return err
}

View File

@ -0,0 +1,197 @@
package controllers_test
import (
"context"
"fmt"
"time"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
hydrav1alpha1 "github.com/ory/hydra-maester/api/v1alpha1"
"github.com/ory/hydra-maester/controllers"
"github.com/ory/hydra-maester/hydra"
apiv1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"
)
const timeout = time.Second * 5
var _ = Describe("OAuth2Client Controller", func() {
Context("in a happy-path scenario", func() {
var tstName = "test"
var tstNamespace = "default"
var tstScopes = "a b c"
var tstClientID = "testClientID"
var tstSecret = "testSecret"
var expectedRequest = reconcile.Request{NamespacedName: types.NamespacedName{Name: tstName, Namespace: tstNamespace}}
It("should call create OAuth2 client in Hydra and a Secret", func() {
s := scheme.Scheme
err := hydrav1alpha1.AddToScheme(s)
Expect(err).NotTo(HaveOccurred())
// Setup the Manager and Controller. Wrap the Controller Reconcile function so it writes each request to a
// channel when it is finished.
mgr, err := manager.New(cfg, manager.Options{Scheme: s})
Expect(err).NotTo(HaveOccurred())
c := mgr.GetClient()
mch := (&mockHydraClient{}).
withSecret(tstSecret).
withClientID(tstClientID)
recFn, requests := SetupTestReconcile(getAPIReconciler(mgr, mch))
//_, requests := SetupTestReconcile(getApiReconciler(mgr))
Expect(add(mgr, recFn)).To(Succeed())
//Start the manager and the controller
stopMgr, mgrStopped := StartTestManager(mgr)
//Ensure manager is stopped properly
defer func() {
close(stopMgr)
mgrStopped.Wait()
}()
instance := testInstance(tstName, tstNamespace, tstScopes)
err = c.Create(context.TODO(), instance)
// The instance object may not be a valid object because it might be missing some required fields.
// Please modify the instance object by adding required fields and then remove the following if statement.
if apierrors.IsInvalid(err) {
Fail(fmt.Sprintf("failed to create object, got an invalid object error: %v", err))
return
}
Expect(err).NotTo(HaveOccurred())
defer c.Delete(context.TODO(), instance)
Eventually(requests, timeout).Should(Receive(Equal(expectedRequest)))
//Verify the created CR instance status
var retrieved hydrav1alpha1.OAuth2Client
ok := client.ObjectKey{Name: tstName, Namespace: tstNamespace}
err = c.Get(context.TODO(), ok, &retrieved)
Expect(err).NotTo(HaveOccurred())
Expect(*retrieved.Status.ClientID).To(Equal(tstClientID))
Expect(*retrieved.Status.Secret).To(Equal(tstName)) //Secret contents is not visible in the CR instance!
//Verify the created Secret
var createdSecret = apiv1.Secret{}
k8sClient.Get(context.TODO(), ok, &createdSecret)
Expect(err).NotTo(HaveOccurred())
Expect(createdSecret.Data["client_secret"]).To(Equal([]byte(tstSecret)))
})
})
})
// add adds a new Controller to mgr with r as the reconcile.Reconciler
func add(mgr manager.Manager, r reconcile.Reconciler) error {
// Create a new controller
c, err := controller.New("api-gateway-controller", mgr, controller.Options{Reconciler: r})
if err != nil {
return err
}
// Watch for changes to Api
err = c.Watch(&source.Kind{Type: &hydrav1alpha1.OAuth2Client{}}, &handler.EnqueueRequestForObject{})
if err != nil {
return err
}
// TODO(user): Modify this to be the types you create
// Uncomment watch a Deployment created by Guestbook - change this for objects you create
//err = c.Watch(&source.Kind{Type: &appsv1.Deployment{}}, &handler.EnqueueRequestForOwner{
// IsController: true,
// OwnerType: &webappv1.Guestbook{},
//})
//if err != nil {
// return err
//}
return nil
}
func getAPIReconciler(mgr ctrl.Manager, mock *mockHydraClient) reconcile.Reconciler {
return &controllers.OAuth2ClientReconciler{
Client: mgr.GetClient(),
Log: ctrl.Log.WithName("controllers").WithName("OAuth2Client"),
HydraClient: mock,
}
}
func testInstance(name, namespace, scopes string) *hydrav1alpha1.OAuth2Client {
return &hydrav1alpha1.OAuth2Client{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Spec: hydrav1alpha1.OAuth2ClientSpec{
GrantTypes: []hydrav1alpha1.GrantType{"client_credentials"},
ResponseTypes: []hydrav1alpha1.ResponseType{"token"},
Scope: scopes,
}}
}
//TODO: Replace with full-fledged mocking framework (mockery/go-mock)
type mockHydraClient struct {
resSecret string
resClientID string
postedData *hydra.OAuth2ClientJSON
}
func (m *mockHydraClient) withSecret(secret string) *mockHydraClient {
m.resSecret = secret
return m
}
func (m *mockHydraClient) withClientID(clientID string) *mockHydraClient {
m.resClientID = clientID
return m
}
//Returns the data previously "stored" by PostOAuth2Client
func (m *mockHydraClient) GetOAuth2Client(id string) (*hydra.OAuth2ClientJSON, bool, error) {
res := &hydra.OAuth2ClientJSON{
ClientID: &m.resClientID,
Secret: &m.resSecret,
Name: m.postedData.Name,
GrantTypes: m.postedData.GrantTypes,
ResponseTypes: m.postedData.ResponseTypes,
Scope: m.postedData.Scope,
}
return res, true, nil
}
func (m *mockHydraClient) PostOAuth2Client(o *hydra.OAuth2ClientJSON) (*hydra.OAuth2ClientJSON, error) {
m.postedData = o
res := &hydra.OAuth2ClientJSON{
ClientID: &m.resClientID,
Secret: &m.resSecret,
Name: o.Name,
GrantTypes: o.GrantTypes,
ResponseTypes: o.ResponseTypes,
Scope: o.Scope,
}
return res, nil
}
func (m *mockHydraClient) DeleteOAuth2Client(id string) error {
panic("Should not be invoked!")
}
func (m *mockHydraClient) PutOAuth2Client(o *hydra.OAuth2ClientJSON) (*hydra.OAuth2ClientJSON, error) {
panic("Should not be invoked!")
}

View File

@ -13,22 +13,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package controllers
package controllers_test
import (
"path/filepath"
"sync"
"testing"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
hydrav1alpha1 "github.com/ory/hydra-maester/api/v1alpha1"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
// +kubebuilder:scaffold:imports
)
@ -55,13 +57,11 @@ var _ = BeforeSuite(func(done Done) {
CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")},
}
cfg, err := testEnv.Start()
var err error
cfg, err = testEnv.Start()
Expect(err).ToNot(HaveOccurred())
Expect(cfg).ToNot(BeNil())
err = hydrav1alpha1.AddToScheme(scheme.Scheme)
Expect(err).NotTo(HaveOccurred())
// +kubebuilder:scaffold:scheme
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
@ -76,3 +76,27 @@ var _ = AfterSuite(func() {
err := testEnv.Stop()
Expect(err).ToNot(HaveOccurred())
})
// SetupTestReconcile returns a reconcile.Reconcile implementation that delegates to inner and
// writes the request to requests after Reconcile is finished.
func SetupTestReconcile(inner reconcile.Reconciler) (reconcile.Reconciler, chan reconcile.Request) {
requests := make(chan reconcile.Request)
fn := reconcile.Func(func(req reconcile.Request) (reconcile.Result, error) {
result, err := inner.Reconcile(req)
requests <- req
return result, err
})
return fn, requests
}
// StartTestManager adds recFn
func StartTestManager(mgr manager.Manager) (chan struct{}, *sync.WaitGroup) {
stop := make(chan struct{})
wg := &sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
Expect(mgr.Start(stop)).NotTo(HaveOccurred())
}()
return stop, wg
}