From efde6f6fd636751d21e7c146abf39e087fd90453 Mon Sep 17 00:00:00 2001 From: Sebastian Widmer Date: Mon, 6 Feb 2023 09:59:14 +0100 Subject: [PATCH 1/7] Add `Invitation` manifest --- apis/user/v1/groupversion_info.go | 21 +++++ apis/user/v1/invitation_types.go | 121 ++++++++++++++++++++++++ apis/user/v1/zz_generated.deepcopy.go | 128 ++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 apis/user/v1/groupversion_info.go create mode 100644 apis/user/v1/invitation_types.go create mode 100644 apis/user/v1/zz_generated.deepcopy.go diff --git a/apis/user/v1/groupversion_info.go b/apis/user/v1/groupversion_info.go new file mode 100644 index 00000000..fc2495f0 --- /dev/null +++ b/apis/user/v1/groupversion_info.go @@ -0,0 +1,21 @@ +// Package v1 contains API Schema definitions for the control-api v1 API group +// +kubebuilder:object:generate=true +// +kubebuilder:skip +// +groupName=user.appuio.io +package v1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "user.appuio.io", Version: "v1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/apis/user/v1/invitation_types.go b/apis/user/v1/invitation_types.go new file mode 100644 index 00000000..cbc769d9 --- /dev/null +++ b/apis/user/v1/invitation_types.go @@ -0,0 +1,121 @@ +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/apiserver-runtime/pkg/builder/resource" +) + +const ( + // ConditionRedeemed is set when the invitation has been redeemed + ConditionRedeemed = "Redeemed" + // ConditionEmailSent is set when the invitation email has been sent + ConditionEmailSent = "EmailSent" +) + +// +kubebuilder:object:root=true + +// Invitation is a representation of an APPUiO Cloud Invitation +type Invitation struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Spec holds the desired invitation state + Spec InvitationSpec `json:"spec,omitempty"` + // Status holds the invitation specific status + Status InvitationStatus `json:"status,omitempty"` +} + +// InvitationSpec defines the desired state of the Invitation +type InvitationSpec struct { + // Note is a free-form text field to add a note to the invitation + Note string `json:"note,omitempty"` + // Email is the email address of the invited user, used to send the invitation + Email string `json:"email,omitempty"` + // TargetRefs is a list of references to the target resources + TargetRefs []TargetRef `json:"targetRefs,omitempty"` +} + +// TargetRef is a reference to a target resource +type TargetRef struct { + // APIGroup is the API group of the target resource + APIGroup string `json:"apiGroup,omitempty"` + // Kind is the kind of the target resource + Kind string `json:"kind,omitempty"` + // Name is the name of the target resource + Name string `json:"name,omitempty"` + // Namespace is the namespace of the target resource + Namespace string `json:"namespace,omitempty"` +} + +// InvitationStatus defines the observed state of the Invitation +type InvitationStatus struct { + // Token is the invitation token + Token string `json:"token,omitempty"` + // ValidUntil is the time when the invitation expires + ValidUntil metav1.Time `json:"validUntil,omitempty"` + // Conditions is a list of conditions for the invitation + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// Invitation needs to implement the builder resource interface +var _ resource.Object = &Invitation{} + +// GetObjectMeta returns the objects meta reference. +func (o *Invitation) GetObjectMeta() *metav1.ObjectMeta { + return &o.ObjectMeta +} + +// GetGroupVersionResource returns the GroupVersionResource for this resource. +// The resource should be the all lowercase and pluralized kind +func (o *Invitation) GetGroupVersionResource() schema.GroupVersionResource { + return schema.GroupVersionResource{ + Group: GroupVersion.Group, + Version: GroupVersion.Version, + Resource: "invitations", + } +} + +// IsStorageVersion returns true if the object is also the internal version -- i.e. is the type defined for the API group or an alias to this object. +// If false, the resource is expected to implement MultiVersionObject interface. +func (o *Invitation) IsStorageVersion() bool { + return true +} + +// NamespaceScoped returns true if the object is namespaced +func (o *Invitation) NamespaceScoped() bool { + return false +} + +// New returns a new instance of the resource +func (o *Invitation) New() runtime.Object { + return &Invitation{} +} + +// NewList return a new list instance of the resource +func (o *Invitation) NewList() runtime.Object { + return &InvitationList{} +} + +// +kubebuilder:object:root=true + +// InvitationList contains a list of Invitations +type InvitationList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []Invitation `json:"items"` +} + +// InvitationList needs to implement the builder resource interface +var _ resource.ObjectList = &InvitationList{} + +// GetListMeta returns the list meta reference. +func (in *InvitationList) GetListMeta() *metav1.ListMeta { + return &in.ListMeta +} + +func init() { + SchemeBuilder.Register(&Invitation{}, &InvitationList{}) +} diff --git a/apis/user/v1/zz_generated.deepcopy.go b/apis/user/v1/zz_generated.deepcopy.go new file mode 100644 index 00000000..c79269e2 --- /dev/null +++ b/apis/user/v1/zz_generated.deepcopy.go @@ -0,0 +1,128 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Invitation) DeepCopyInto(out *Invitation) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Invitation. +func (in *Invitation) DeepCopy() *Invitation { + if in == nil { + return nil + } + out := new(Invitation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Invitation) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InvitationList) DeepCopyInto(out *InvitationList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Invitation, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InvitationList. +func (in *InvitationList) DeepCopy() *InvitationList { + if in == nil { + return nil + } + out := new(InvitationList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *InvitationList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InvitationSpec) DeepCopyInto(out *InvitationSpec) { + *out = *in + if in.TargetRefs != nil { + in, out := &in.TargetRefs, &out.TargetRefs + *out = make([]TargetRef, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InvitationSpec. +func (in *InvitationSpec) DeepCopy() *InvitationSpec { + if in == nil { + return nil + } + out := new(InvitationSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InvitationStatus) DeepCopyInto(out *InvitationStatus) { + *out = *in + in.ValidUntil.DeepCopyInto(&out.ValidUntil) + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InvitationStatus. +func (in *InvitationStatus) DeepCopy() *InvitationStatus { + if in == nil { + return nil + } + out := new(InvitationStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TargetRef) DeepCopyInto(out *TargetRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TargetRef. +func (in *TargetRef) DeepCopy() *TargetRef { + if in == nil { + return nil + } + out := new(TargetRef) + in.DeepCopyInto(out) + return out +} From a043535c76609c2d7992b4a33342320dba4e0ffe Mon Sep 17 00:00:00 2001 From: Sebastian Widmer Date: Fri, 10 Feb 2023 16:06:13 +0100 Subject: [PATCH 2/7] Generic storage using secrets as backing + Invitation resource plumbing --- README.md | 15 + api.go | 15 + apiserver/authwrapper/storage_test.go | 2 +- apiserver/billing/rbac_test.go | 2 +- apiserver/secretstorage/storage.go | 364 ++++++++++++++++++ apiserver/secretstorage/storage_test.go | 88 +++++ .../testresource/testresource.go | 0 .../testresource/zz_generated.deepcopy.go | 0 config/deployment/apiserver/apiservice.yaml | 15 + config/examples/invitation.yaml | 30 ++ 10 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 apiserver/secretstorage/storage.go create mode 100644 apiserver/secretstorage/storage_test.go rename apiserver/{authwrapper => }/testresource/testresource.go (100%) rename apiserver/{authwrapper => }/testresource/zz_generated.deepcopy.go (100%) create mode 100644 config/examples/invitation.yaml diff --git a/README.md b/README.md index 0aaa3633..93d3dd21 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,21 @@ spec: port: 9443 version: v1 --- +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v1.user.appuio.io +spec: + insecureSkipTLSVerify: true + group: user.appuio.io + groupPriorityMinimum: 1000 + versionPriority: 15 + service: + name: apiserver + namespace: default + port: 9443 + version: v1 +--- apiVersion: v1 kind: Service metadata: diff --git a/api.go b/api.go index 8395f080..f61121c4 100644 --- a/api.go +++ b/api.go @@ -11,14 +11,18 @@ import ( genericregistry "k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/rest" "sigs.k8s.io/apiserver-runtime/pkg/builder" + "sigs.k8s.io/apiserver-runtime/pkg/util/loopback" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" billingv1 "github.com/appuio/control-api/apis/billing/v1" orgv1 "github.com/appuio/control-api/apis/organization/v1" + userv1 "github.com/appuio/control-api/apis/user/v1" "github.com/appuio/control-api/apiserver/authwrapper" billingStore "github.com/appuio/control-api/apiserver/billing" "github.com/appuio/control-api/apiserver/billing/odoostorage" orgStore "github.com/appuio/control-api/apiserver/organization" + "github.com/appuio/control-api/apiserver/secretstorage" ) type organizationStatusRegisterer struct { @@ -44,6 +48,17 @@ func APICommand() *cobra.Command { WithResourceAndHandler(&orgv1.Organization{}, ost). WithResourceAndHandler(organizationStatusRegisterer{&orgv1.Organization{}}, ost). WithResourceAndHandler(&billingv1.BillingEntity{}, ob.Build). + WithResourceAndHandler(&userv1.Invitation{}, func(s *runtime.Scheme, g genericregistry.RESTOptionsGetter) (rest.Storage, error) { + c, err := client.NewWithWatch(loopback.GetLoopbackMasterClientConfig(), client.Options{}) + if err != nil { + return nil, err + } + err = userv1.AddToScheme(c.Scheme()) + if err != nil { + return nil, err + } + return secretstorage.NewStorage(&userv1.Invitation{}, c, "default") + }). WithoutEtcd(). ExposeLoopbackAuthorizer(). ExposeLoopbackMasterClientConfig(). diff --git a/apiserver/authwrapper/storage_test.go b/apiserver/authwrapper/storage_test.go index 7951124a..c4139e4e 100644 --- a/apiserver/authwrapper/storage_test.go +++ b/apiserver/authwrapper/storage_test.go @@ -18,7 +18,7 @@ import ( "github.com/appuio/control-api/apiserver/authwrapper" "github.com/appuio/control-api/apiserver/authwrapper/mock" - "github.com/appuio/control-api/apiserver/authwrapper/testresource" + "github.com/appuio/control-api/apiserver/testresource" ) var gvr = func() metav1.GroupVersionResource { diff --git a/apiserver/billing/rbac_test.go b/apiserver/billing/rbac_test.go index deededb0..7723ce0b 100644 --- a/apiserver/billing/rbac_test.go +++ b/apiserver/billing/rbac_test.go @@ -21,7 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/appuio/control-api/apiserver/authwrapper/mock" - "github.com/appuio/control-api/apiserver/authwrapper/testresource" + "github.com/appuio/control-api/apiserver/testresource" ) func Test_createRBACWrapper(t *testing.T) { diff --git a/apiserver/secretstorage/storage.go b/apiserver/secretstorage/storage.go new file mode 100644 index 00000000..e7bb4638 --- /dev/null +++ b/apiserver/secretstorage/storage.go @@ -0,0 +1,364 @@ +// Package secretstorage implements a storage backend for resources implementing apiserver-runtime's resource.Object interface. +// The storage backend stores the object in a kubernetes secret. +// The secret is named after the object and the object is stored in the secret's data field. +// Warning: Not all features of the storage backend are implemented. +// Missing features: +// - Field selectors +// - Label selectors +// - you tell me +// UID, CreationTimestamp and ResourceVersion are taken from the secret's metadata. +// UID are namespaced UUIDs, generated from the object's UID and a fixed random UUID as the namespace. +package secretstorage + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/google/uuid" + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/registry/rest" + "k8s.io/apiserver/pkg/server/storage" + "sigs.k8s.io/apiserver-runtime/pkg/builder/resource" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + secretObjectKey = "object" +) + +var uuidNamespace = uuid.MustParse("5FC62D70-B17C-44A2-9BBB-6B3DD71C4A2E") + +type secretStorage struct { + // object is the type of object this storage is for + object resource.Object + // client is the client used to interact with the Kubernetes API. + client client.WithWatch + // codec is the codec used to encode and decode the object. + codec runtime.Codec + // namespace is the namespace to store the secrets in. + namespace string +} + +// NewStorage creates a new storage for the given object. +func NewStorage(object resource.Object, cc client.WithWatch, backingNS string) (rest.StandardStorage, error) { + // Supporting namespaced objects would need some way to create a unique hash out of the namespace and name. + // k8s.io/apiserver/pkg/endpoints/request.NamespaceFrom(ctx) + if object.NamespaceScoped() { + return nil, fmt.Errorf("namespace scoped objects are not yet supported") + } + + scheme := cc.Scheme() + + vs := scheme.PrioritizedVersionsForGroup(object.GetGroupVersionResource().Group) + if len(vs) == 0 { + return nil, fmt.Errorf("no versions registered for group %q", object.GetGroupVersionResource().Group) + } + + codec, _, err := storage.NewStorageCodec(storage.StorageCodecConfig{ + StorageMediaType: runtime.ContentTypeJSON, + StorageSerializer: serializer.NewCodecFactory(scheme), + StorageVersion: vs[0], + MemoryVersion: vs[0], + }) + if err != nil { + return nil, err + } + + return &secretStorage{ + object: object, + client: cc, + codec: codec, + namespace: backingNS, + }, nil +} + +// New implements rest.Storage +func (s *secretStorage) New() runtime.Object { + return s.object.New() +} + +func (s *secretStorage) NewList() runtime.Object { + return s.object.NewList() +} + +// Destroy implements rest.Storage +func (s *secretStorage) Destroy() {} + +// NamespaceScoped implements rest.Scoper +func (s *secretStorage) NamespaceScoped() bool { + return s.object.NamespaceScoped() +} + +func (s *secretStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, opts *metav1.CreateOptions) (runtime.Object, error) { + return s.create(ctx, obj, createValidation, opts) +} + +func (s *secretStorage) ConvertToTable(ctx context.Context, obj runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return rest.NewDefaultTableConvertor(s.object.GetGroupVersionResource().GroupResource()).ConvertToTable(ctx, obj, tableOptions) +} + +func (s *secretStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + obj, err := s.Get(ctx, name, &metav1.GetOptions{}) + if err != nil { + return nil, false, err + } + + if deleteValidation != nil { + if err := deleteValidation(ctx, obj); err != nil { + return nil, false, fmt.Errorf("failed to validate object: %w", err) + } + } + + return obj, true, s.client.Delete(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: s.getBackingNamespace(), + }, + }, &client.DeleteOptions{ + GracePeriodSeconds: options.GracePeriodSeconds, + Preconditions: options.Preconditions, + PropagationPolicy: options.PropagationPolicy, + DryRun: options.DryRun, + }) +} + +func (s *secretStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *metainternalversion.ListOptions) (runtime.Object, error) { + return nil, fmt.Errorf("not implemented") +} + +func (s *secretStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { + rs := corev1.Secret{} + + if err := s.client.Get(ctx, client.ObjectKey{Name: name, Namespace: s.getBackingNamespace()}, &rs); err != nil { + // Wrapping the not found error breaks kubectl apply (404) + return nil, err + } + + return s.objectFromBackingSecret(&rs) +} + +func (s *secretStorage) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) { + rsl := &corev1.SecretList{} + if err := s.client.List(ctx, rsl, &client.ListOptions{ + // TODO(swi): Should we add labels to the secret so we can filter on them? + // Those would need to be hashed to not introduce collisions with controllers tracking state through labels like argocd does. + // LabelSelector: nil, + // FieldSelector: nil, + Namespace: s.getBackingNamespace(), + Limit: options.Limit, + Continue: options.Continue, + }); err != nil { + return nil, fmt.Errorf("failed to list secrets: %w", err) + } + + objList := s.object.NewList() + l, err := apimeta.ExtractList(objList) + if err != nil { + return nil, err + } + + for _, rs := range rsl.Items { + obj, err := s.objectFromBackingSecret(&rs) + if err != nil { + return nil, fmt.Errorf("failed to decode object from secret: %w", err) + } + l = append(l, obj) + } + + if err := apimeta.SetList(objList, l); err != nil { + return nil, err + } + + return objList, nil +} + +func (s *secretStorage) Watch(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error) { + rsl := &corev1.SecretList{} + w, err := s.client.Watch(ctx, rsl, &client.ListOptions{ + // TODO(swi): Should we add labels to the secret so we can filter on them? + // Those would need to be hashed to not introduce collisions with controllers tracking state through labels like argocd does. + // LabelSelector: nil, + // FieldSelector: nil, + Namespace: s.getBackingNamespace(), + Limit: options.Limit, + Continue: options.Continue, + }) + if err != nil { + return nil, fmt.Errorf("failed to list secrets: %w", err) + } + + return watch.Filter(w, func(in watch.Event) (out watch.Event, keep bool) { + if in.Object == nil { + // This should never happen, let downstream deal with it + return in, true + } + rs, ok := in.Object.(*corev1.Secret) + if !ok { + // We received a non Secret object + // This is most likely an error so we pass it on + return in, true + } + + obj, err := s.objectFromBackingSecret(rs) + if err != nil { + return watch.Event{ + Type: watch.Error, + Object: &metav1.Status{Message: fmt.Sprintf("failed to decode object: %v", err)}, + }, true + } + in.Object = obj + + return in, true + }), nil +} + +func (s *secretStorage) Update( + ctx context.Context, name string, + objInfo rest.UpdatedObjectInfo, + createValidation rest.ValidateObjectFunc, + updateValidation rest.ValidateObjectUpdateFunc, + forceAllowCreate bool, + options *metav1.UpdateOptions, +) (runtime.Object, bool, error) { + var isCreate bool + rs := &corev1.Secret{} + err := s.client.Get(ctx, types.NamespacedName{Name: name, Namespace: s.getBackingNamespace()}, rs) + if err != nil { + if !forceAllowCreate { + return nil, false, fmt.Errorf("failed to get old object: %w", err) + } + isCreate = true + } + + oldObj, err := s.objectFromBackingSecret(rs) + if err != nil { + return nil, false, fmt.Errorf("failed to decode object: %w", err) + } + + newObj, err := objInfo.UpdatedObject(ctx, oldObj) + if err != nil { + return nil, false, fmt.Errorf("failed to calculate new object: %w", err) + } + + if isCreate { + if createValidation != nil { + if err := createValidation(ctx, newObj); err != nil { + return nil, false, err + } + } + newObj, err = s.create(ctx, newObj, createValidation, &metav1.CreateOptions{DryRun: options.DryRun}) + return newObj, true, err + } + + if updateValidation != nil { + if err := updateValidation(ctx, newObj, oldObj); err != nil { + return nil, false, fmt.Errorf("failed to validate new object: %w", err) + } + } + + newObjRaw := &bytes.Buffer{} + err = s.codec.Encode(newObj, newObjRaw) + if err != nil { + return nil, false, fmt.Errorf("failed to encode new object: %w", err) + } + + p, err := objectPatch(newObjRaw.Bytes()) + if err != nil { + return nil, false, fmt.Errorf("failed to create patch: %w", err) + } + + err = s.client.Patch(ctx, rs, p, &client.PatchOptions{DryRun: options.DryRun}) + if err != nil { + return newObj, false, fmt.Errorf("failed to update backing secret: %w", err) + } + + return newObj, false, nil +} + +func (s *secretStorage) create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, opts *metav1.CreateOptions) (runtime.Object, error) { + ac, err := apimeta.Accessor(obj) + if err != nil { + return nil, fmt.Errorf("failed to access object metadata: %w", err) + } + + if createValidation != nil { + if err := createValidation(ctx, obj); err != nil { + return nil, fmt.Errorf("failed to validate object: %w", err) + } + } + + raw := &bytes.Buffer{} + if err := s.codec.Encode(obj, raw); err != nil { + return nil, fmt.Errorf("failed to encode object: %w", err) + } + rs := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: ac.GetName(), + Namespace: s.getBackingNamespace(), + }, + Data: map[string][]byte{ + secretObjectKey: raw.Bytes(), + }, + } + + if err := s.client.Create(ctx, &rs, &client.CreateOptions{ + DryRun: opts.DryRun, + }); err != nil { + return nil, fmt.Errorf("failed to create secret: %w", err) + } + + return obj, nil +} + +func (s *secretStorage) getBackingNamespace() string { + if s.namespace != "" { + return s.namespace + } + return "default" +} + +func (s *secretStorage) objectFromBackingSecret(rs *corev1.Secret) (runtime.Object, error) { + obj := s.object.New() + if _, _, err := s.codec.Decode(rs.Data[secretObjectKey], nil, obj); err != nil { + return nil, fmt.Errorf("failed to decode object: %w", err) + } + + ac, err := apimeta.Accessor(obj) + if err != nil { + return nil, fmt.Errorf("failed to access object metadata: %w", err) + } + + // Use the backing secret's creation timestamp and resource version + // Resource version does not need to be globally unique but needs to change on every update + ac.SetCreationTimestamp(rs.CreationTimestamp) + ac.SetResourceVersion(rs.ResourceVersion) + // Use the backing secret's UID but namespace it to avoid collisions + // UID should be globally unique and not change on updates (note that OCP sometimes reuses UIDs eg. Project/Namespace) + if rs.UID != "" { + // creates a v5 UUID based on the backing secret's UID and a UUID namespace + ac.SetUID(types.UID(uuid.NewSHA1(uuidNamespace, []byte(rs.UID)).String())) + } else { + ac.SetUID("") + } + + return obj, nil +} + +func objectPatch(serialized []byte) (client.Patch, error) { + jp, err := json.Marshal(map[string]any{ + "data": map[string]any{ + secretObjectKey: base64.StdEncoding.EncodeToString(serialized), + }, + }) + return client.RawPatch(types.StrategicMergePatchType, jp), err +} diff --git a/apiserver/secretstorage/storage_test.go b/apiserver/secretstorage/storage_test.go new file mode 100644 index 00000000..2287a97c --- /dev/null +++ b/apiserver/secretstorage/storage_test.go @@ -0,0 +1,88 @@ +package secretstorage_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/registry/rest" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/appuio/control-api/apiserver/secretstorage" + "github.com/appuio/control-api/apiserver/testresource" +) + +func TestRoundtrip(t *testing.T) { + c := buildClient(t) + + s, err := secretstorage.NewStorage(new(testresource.TestResource), c, "default") + require.NoError(t, err) + + w, err := s.Watch(context.Background(), &metainternalversion.ListOptions{}) + require.NoError(t, err) + defer w.Stop() + + inv := &testresource.TestResource{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Field1: "test", + } + + _, err = s.Create(context.Background(), inv, nil, &metav1.CreateOptions{}) + require.NoError(t, err) + + secret := &corev1.Secret{} + err = c.Get(context.Background(), client.ObjectKey{Name: "test", Namespace: "default"}, secret) + require.NoError(t, err) + + invOut, err := s.Get(context.Background(), "test", &metav1.GetOptions{}) + require.NoError(t, err) + + require.Equal(t, inv.Field1, invOut.(*testresource.TestResource).Field1) + + // Update the object + inv.Field1 = "updated" + _, _, err = s.Update(context.Background(), "test", rest.DefaultUpdatedObjectInfo(inv), nil, nil, false, &metav1.UpdateOptions{}) + require.NoError(t, err) + + list, err := s.List(context.Background(), &metainternalversion.ListOptions{}) + require.NoError(t, err) + require.Len(t, list.(*testresource.TestResourceList).Items, 1) + require.Equal(t, "updated", list.(*testresource.TestResourceList).Items[0].Field1) + + // Delete the object + _, _, err = s.Delete(context.Background(), "test", nil, &metav1.DeleteOptions{}) + require.NoError(t, err) + list, err = s.List(context.Background(), &metainternalversion.ListOptions{}) + require.NoError(t, err) + require.Len(t, list.(*testresource.TestResourceList).Items, 0) + + require.Eventually(t, func() bool { + select { + case event := <-w.ResultChan(): + return event.Type == watch.Deleted + default: + return false + } + }, 200*time.Millisecond, time.Microsecond) +} + +func buildClient(t *testing.T, initObjs ...client.Object) client.WithWatch { + scheme := runtime.NewScheme() + require.NoError(t, testresource.AddToScheme(scheme)) + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(initObjs...). + Build() +} diff --git a/apiserver/authwrapper/testresource/testresource.go b/apiserver/testresource/testresource.go similarity index 100% rename from apiserver/authwrapper/testresource/testresource.go rename to apiserver/testresource/testresource.go diff --git a/apiserver/authwrapper/testresource/zz_generated.deepcopy.go b/apiserver/testresource/zz_generated.deepcopy.go similarity index 100% rename from apiserver/authwrapper/testresource/zz_generated.deepcopy.go rename to apiserver/testresource/zz_generated.deepcopy.go diff --git a/config/deployment/apiserver/apiservice.yaml b/config/deployment/apiserver/apiservice.yaml index f1daf69a..2d1d02ca 100644 --- a/config/deployment/apiserver/apiservice.yaml +++ b/config/deployment/apiserver/apiservice.yaml @@ -25,3 +25,18 @@ spec: name: control-api-apiserver namespace: control-api version: v1 +--- +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v1.user.appuio.io +spec: + insecureSkipTLSVerify: true + group: user.appuio.io + groupPriorityMinimum: 1000 + versionPriority: 15 + service: + name: apiserver + namespace: default + port: 9443 + version: v1 diff --git a/config/examples/invitation.yaml b/config/examples/invitation.yaml new file mode 100644 index 00000000..5e2e0e92 --- /dev/null +++ b/config/examples/invitation.yaml @@ -0,0 +1,30 @@ +apiVersion: user.appuio.io/v1 +kind: Invitation +metadata: + name: e303b166-5d66-4151-8f5f-b84ba84a7559 +spec: + note: "New employee dev1 (Delilah Vernon) starting 2020-04-01" + email: "dev1.int@acme.com" + # For billing entity invitations + targetRefs: + - apiGroup: "rbac.authorization.k8s.io" + kind: "ClusterRoleBinding" + name: "be-123-viewer" + namespace: "" + # OR + # For organization invitations + - apiGroup: "appuio.io" + kind: "OrganizationMembers" + name: "members" + namespace: "acme" + - apiGroup: "rbac.authorization.k8s.io" + kind: "RoleBinding" + name: "control-api:organization-admin" + namespace: "acme" + # OR + # For teams invitations + - apiGroup: "appuio.io" + kind: "Team" + name: "dev" + namespace: "acme" +status: {} From 6e6b87374ad8b406881cb8648d364dc7584dd52a Mon Sep 17 00:00:00 2001 From: Sebastian Widmer Date: Mon, 13 Feb 2023 08:39:00 +0100 Subject: [PATCH 3/7] Status updates --- api.go | 48 ++++----- apis/user/v1/invitation_types.go | 16 ++- apiserver/secretstorage/status.go | 27 +++++ apiserver/secretstorage/status_test.go | 14 +++ apiserver/secretstorage/storage.go | 42 +++++++- apiserver/secretstorage/storage_test.go | 58 ++++++++-- apiserver/testresource/with_status.go | 100 ++++++++++++++++++ apiserver/testresource/with_status_test.go | 14 +++ .../testresource/zz_generated.deepcopy.go | 73 +++++++++++++ apiserver/user/invitation_storage.go | 44 ++++++++ 10 files changed, 404 insertions(+), 32 deletions(-) create mode 100644 apiserver/secretstorage/status.go create mode 100644 apiserver/secretstorage/status_test.go create mode 100644 apiserver/testresource/with_status.go create mode 100644 apiserver/testresource/with_status_test.go create mode 100644 apiserver/user/invitation_storage.go diff --git a/api.go b/api.go index f61121c4..6c1f2d68 100644 --- a/api.go +++ b/api.go @@ -11,9 +11,7 @@ import ( genericregistry "k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/rest" "sigs.k8s.io/apiserver-runtime/pkg/builder" - "sigs.k8s.io/apiserver-runtime/pkg/util/loopback" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" billingv1 "github.com/appuio/control-api/apis/billing/v1" orgv1 "github.com/appuio/control-api/apis/organization/v1" @@ -22,19 +20,9 @@ import ( billingStore "github.com/appuio/control-api/apiserver/billing" "github.com/appuio/control-api/apiserver/billing/odoostorage" orgStore "github.com/appuio/control-api/apiserver/organization" - "github.com/appuio/control-api/apiserver/secretstorage" + "github.com/appuio/control-api/apiserver/user" ) -type organizationStatusRegisterer struct { - *orgv1.Organization -} - -func (o organizationStatusRegisterer) GetGroupVersionResource() schema.GroupVersionResource { - gvr := o.Organization.GetGroupVersionResource() - gvr.Resource = fmt.Sprintf("%s/status", gvr.Resource) - return gvr -} - // APICommand creates a new command allowing to start the API server func APICommand() *cobra.Command { roles := []string{} @@ -43,22 +31,14 @@ func APICommand() *cobra.Command { ob := &odooStorageBuilder{} ost := orgStore.New(&roles, &usernamePrefix, &allowEmptyBillingEntity) + ib := &invitationStorageBuilder{} cmd, err := builder.APIServer. WithResourceAndHandler(&orgv1.Organization{}, ost). WithResourceAndHandler(organizationStatusRegisterer{&orgv1.Organization{}}, ost). WithResourceAndHandler(&billingv1.BillingEntity{}, ob.Build). - WithResourceAndHandler(&userv1.Invitation{}, func(s *runtime.Scheme, g genericregistry.RESTOptionsGetter) (rest.Storage, error) { - c, err := client.NewWithWatch(loopback.GetLoopbackMasterClientConfig(), client.Options{}) - if err != nil { - return nil, err - } - err = userv1.AddToScheme(c.Scheme()) - if err != nil { - return nil, err - } - return secretstorage.NewStorage(&userv1.Invitation{}, c, "default") - }). + WithResourceAndHandler(&userv1.Invitation{}, ib.Build). + // WithResourceAndHandler(secretstorage.NewStatusSubResourceRegisterer(&userv1.Invitation{}), ib.Build). WithoutEtcd(). ExposeLoopbackAuthorizer(). ExposeLoopbackMasterClientConfig(). @@ -76,6 +56,8 @@ func APICommand() *cobra.Command { cmd.Flags().StringVar(&ob.odoo8URL, "billing-entity-odoo8-url", "http://localhost:8069", "URL of the Odoo instance to use for billing entities") cmd.Flags().BoolVar(&ob.odoo8DebugTransport, "billing-entity-odoo8-debug-transport", false, "Enable debug logging for the Odoo transport") + cmd.Flags().StringVar(&ib.backingNS, "invitation-storage-backing-ns", "default", "Namespace to store invitation secrets in") + rf := cmd.Run cmd.Run = func(cmd *cobra.Command, args []string) { ctrl.Log.WithName("setup").WithValues( @@ -111,3 +93,21 @@ func (o *odooStorageBuilder) Build(s *runtime.Scheme, g genericregistry.RESTOpti return nil, fmt.Errorf("unknown billing entity storage: %s", o.billingEntityStorage) } } + +type invitationStorageBuilder struct { + backingNS string +} + +func (i *invitationStorageBuilder) Build(s *runtime.Scheme, g genericregistry.RESTOptionsGetter) (rest.Storage, error) { + return user.NewInvitationStorage(i.backingNS)(s, g) +} + +type organizationStatusRegisterer struct { + *orgv1.Organization +} + +func (o organizationStatusRegisterer) GetGroupVersionResource() schema.GroupVersionResource { + gvr := o.Organization.GetGroupVersionResource() + gvr.Resource = fmt.Sprintf("%s/status", gvr.Resource) + return gvr +} diff --git a/apis/user/v1/invitation_types.go b/apis/user/v1/invitation_types.go index cbc769d9..b8adc24d 100644 --- a/apis/user/v1/invitation_types.go +++ b/apis/user/v1/invitation_types.go @@ -60,7 +60,7 @@ type InvitationStatus struct { } // Invitation needs to implement the builder resource interface -var _ resource.Object = &Invitation{} +var _ resource.ObjectWithStatusSubResource = &Invitation{} // GetObjectMeta returns the objects meta reference. func (o *Invitation) GetObjectMeta() *metav1.ObjectMeta { @@ -98,6 +98,20 @@ func (o *Invitation) NewList() runtime.Object { return &InvitationList{} } +// GetStatus returns the status of the resource +func (o *Invitation) GetStatus() resource.StatusSubResource { + return &o.Status +} + +// CopyTo copies the status to the given parent resource +func (s *InvitationStatus) CopyTo(parent resource.ObjectWithStatusSubResource) { + parent.(*Invitation).Status = *s.DeepCopy() +} + +func (s InvitationStatus) SubResourceName() string { + return "status" +} + // +kubebuilder:object:root=true // InvitationList contains a list of Invitations diff --git a/apiserver/secretstorage/status.go b/apiserver/secretstorage/status.go new file mode 100644 index 00000000..46daee96 --- /dev/null +++ b/apiserver/secretstorage/status.go @@ -0,0 +1,27 @@ +package secretstorage + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/apiserver-runtime/pkg/builder/resource" +) + +// NewStatusSubResourceRegisterer returns a helper type to register a status subresource for a resource. +// +// builder.APIServer. +// WithResourceAndHandler(&Resource{}, storage). +// WithResourceAndHandler(StatusSubResourceRegisterer{&Resource{}}, storage). +func NewStatusSubResourceRegisterer(o resource.ObjectWithStatusSubResource) resource.ObjectWithStatusSubResource { + return statusSubResourceRegisterer{o} +} + +type statusSubResourceRegisterer struct { + resource.ObjectWithStatusSubResource +} + +func (o statusSubResourceRegisterer) GetGroupVersionResource() schema.GroupVersionResource { + gvr := o.ObjectWithStatusSubResource.GetGroupVersionResource() + gvr.Resource = fmt.Sprintf("%s/status", gvr.Resource) + return gvr +} diff --git a/apiserver/secretstorage/status_test.go b/apiserver/secretstorage/status_test.go new file mode 100644 index 00000000..745c0a90 --- /dev/null +++ b/apiserver/secretstorage/status_test.go @@ -0,0 +1,14 @@ +package secretstorage + +import ( + "testing" + + "github.com/appuio/control-api/apiserver/testresource" + "github.com/stretchr/testify/require" +) + +func TestStatusSubResourceRegisterer(t *testing.T) { + obj := &testresource.TestResourceWithStatus{} + gvr := statusSubResourceRegisterer{obj}.GetGroupVersionResource() + require.Equal(t, obj.GetGroupVersionResource().Resource+"/status", gvr.Resource) +} diff --git a/apiserver/secretstorage/storage.go b/apiserver/secretstorage/storage.go index e7bb4638..89855a4d 100644 --- a/apiserver/secretstorage/storage.go +++ b/apiserver/secretstorage/storage.go @@ -15,6 +15,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "github.com/google/uuid" @@ -26,6 +27,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/server/storage" "sigs.k8s.io/apiserver-runtime/pkg/builder/resource" @@ -49,8 +51,13 @@ type secretStorage struct { namespace string } +type ScopedStandardStorage interface { + rest.StandardStorage + rest.Scoper +} + // NewStorage creates a new storage for the given object. -func NewStorage(object resource.Object, cc client.WithWatch, backingNS string) (rest.StandardStorage, error) { +func NewStorage(object resource.Object, cc client.WithWatch, backingNS string) (ScopedStandardStorage, error) { // Supporting namespaced objects would need some way to create a unique hash out of the namespace and name. // k8s.io/apiserver/pkg/endpoints/request.NamespaceFrom(ctx) if object.NamespaceScoped() { @@ -245,6 +252,7 @@ func (s *secretStorage) Update( return nil, false, fmt.Errorf("failed to decode object: %w", err) } + objInfo = rest.WrapUpdatedObjectInfo(objInfo, filterStatusUpdates) newObj, err := objInfo.UpdatedObject(ctx, oldObj) if err != nil { return nil, false, fmt.Errorf("failed to calculate new object: %w", err) @@ -291,6 +299,13 @@ func (s *secretStorage) create(ctx context.Context, obj runtime.Object, createVa return nil, fmt.Errorf("failed to access object metadata: %w", err) } + // Add empty status if the object supports it + // Status can only be modified through the status subresource and update calls + st, hasStatus := obj.(resource.ObjectWithStatusSubResource) + if hasStatus { + st.New().(resource.ObjectWithStatusSubResource).GetStatus().CopyTo(st) + } + if createValidation != nil { if err := createValidation(ctx, obj); err != nil { return nil, fmt.Errorf("failed to validate object: %w", err) @@ -362,3 +377,28 @@ func objectPatch(serialized []byte) (client.Patch, error) { }) return client.RawPatch(types.StrategicMergePatchType, jp), err } + +// filterStatusUpdates handles the status subresource if the object supports it +func filterStatusUpdates(ctx context.Context, newObj, oldObj runtime.Object) (transformedNewObj runtime.Object, err error) { + oldWithStatus, ok := oldObj.(resource.ObjectWithStatusSubResource) + if !ok { + return newObj, nil + } + newWithStatus, ok := newObj.(resource.ObjectWithStatusSubResource) + if !ok { + return newObj, nil + } + requestInfo, found := request.RequestInfoFrom(ctx) + if !found { + return nil, errors.New("no RequestInfo found in the context") + } + + if requestInfo.Subresource == "status" { + withUpdatedStatus := oldWithStatus.DeepCopyObject().(resource.ObjectWithStatusSubResource) + newWithStatus.GetStatus().CopyTo(withUpdatedStatus) + return withUpdatedStatus, nil + } + // Status must be updated through the status subresource + oldWithStatus.GetStatus().CopyTo(newWithStatus) + return newWithStatus, nil +} diff --git a/apiserver/secretstorage/storage_test.go b/apiserver/secretstorage/storage_test.go index 2287a97c..ca532f26 100644 --- a/apiserver/secretstorage/storage_test.go +++ b/apiserver/secretstorage/storage_test.go @@ -11,6 +11,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client" @@ -22,7 +23,6 @@ import ( func TestRoundtrip(t *testing.T) { c := buildClient(t) - s, err := secretstorage.NewStorage(new(testresource.TestResource), c, "default") require.NoError(t, err) @@ -30,14 +30,14 @@ func TestRoundtrip(t *testing.T) { require.NoError(t, err) defer w.Stop() - inv := &testresource.TestResource{ + ttr := &testresource.TestResource{ ObjectMeta: metav1.ObjectMeta{ Name: "test", }, Field1: "test", } - _, err = s.Create(context.Background(), inv, nil, &metav1.CreateOptions{}) + _, err = s.Create(context.Background(), ttr, nil, &metav1.CreateOptions{}) require.NoError(t, err) secret := &corev1.Secret{} @@ -47,11 +47,11 @@ func TestRoundtrip(t *testing.T) { invOut, err := s.Get(context.Background(), "test", &metav1.GetOptions{}) require.NoError(t, err) - require.Equal(t, inv.Field1, invOut.(*testresource.TestResource).Field1) + require.Equal(t, ttr.Field1, invOut.(*testresource.TestResource).Field1) // Update the object - inv.Field1 = "updated" - _, _, err = s.Update(context.Background(), "test", rest.DefaultUpdatedObjectInfo(inv), nil, nil, false, &metav1.UpdateOptions{}) + ttr.Field1 = "updated" + _, _, err = s.Update(context.Background(), "test", rest.DefaultUpdatedObjectInfo(ttr), nil, nil, false, &metav1.UpdateOptions{}) require.NoError(t, err) list, err := s.List(context.Background(), &metainternalversion.ListOptions{}) @@ -76,6 +76,52 @@ func TestRoundtrip(t *testing.T) { }, 200*time.Millisecond, time.Microsecond) } +func TestStatusSubresource(t *testing.T) { + c := buildClient(t) + s, err := secretstorage.NewStorage(new(testresource.TestResourceWithStatus), c, "default") + require.NoError(t, err) + + ttr := &testresource.TestResourceWithStatus{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Field1: "test", + Status: testresource.TestResourceWithStatusStatus{ + Num: 7, + }, + } + + _, err = s.Create(context.Background(), ttr, nil, &metav1.CreateOptions{}) + require.NoError(t, err) + // Empty status on create, can only be set via the status subresource + require.Equal(t, 0, ttr.GetStatus().(*testresource.TestResourceWithStatusStatus).Num) + testStatusValue(t, s, 0) + + // Update the status + ttr.Status.Num = 42 + _, _, err = s.Update( + request.WithRequestInfo(request.NewContext(), &request.RequestInfo{Subresource: "status"}), + "test", rest.DefaultUpdatedObjectInfo(ttr), nil, nil, false, &metav1.UpdateOptions{}) + require.NoError(t, err) + testStatusValue(t, s, 42) + + // Non status update should not change the status + ttr.Status.Num = 12 + _, _, err = s.Update( + request.WithRequestInfo(request.NewContext(), &request.RequestInfo{}), + "test", rest.DefaultUpdatedObjectInfo(ttr), nil, nil, false, &metav1.UpdateOptions{}) + require.NoError(t, err) + testStatusValue(t, s, 42) +} + +func testStatusValue(t *testing.T, s rest.Getter, expected int) { + t.Helper() + + ttr, err := s.Get(context.Background(), "test", &metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, expected, ttr.(*testresource.TestResourceWithStatus).GetStatus().(*testresource.TestResourceWithStatusStatus).Num) +} + func buildClient(t *testing.T, initObjs ...client.Object) client.WithWatch { scheme := runtime.NewScheme() require.NoError(t, testresource.AddToScheme(scheme)) diff --git a/apiserver/testresource/with_status.go b/apiserver/testresource/with_status.go new file mode 100644 index 00000000..8ad08b2e --- /dev/null +++ b/apiserver/testresource/with_status.go @@ -0,0 +1,100 @@ +// +kubebuilder:object:generate=true +// +kubebuilder:skip +// +groupName=test +package testresource + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/apiserver-runtime/pkg/builder/resource" +) + +// +kubebuilder:object:root=true +// TestResourceWithStatus implements resource.Object +type TestResourceWithStatus struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Field1 string `json:"field1"` + + Status TestResourceWithStatusStatus `json:"status"` +} + +type TestResourceWithStatusStatus struct { + Num int `json:"num"` +} + +// TestResourceWithStatus needs to implement the builder resource interface +var _ resource.ObjectWithStatusSubResource = &TestResourceWithStatus{} + +// GetObjectMeta returns the objects meta reference. +func (o *TestResourceWithStatus) GetObjectMeta() *metav1.ObjectMeta { + return &o.ObjectMeta +} + +// GetGroupVersionResource returns the GroupVersionResource for this resource. +// The resource should be the all lowercase and pluralized kind +func (o *TestResourceWithStatus) GetGroupVersionResource() schema.GroupVersionResource { + return schema.GroupVersionResource{ + Group: GroupVersion.Group, + Version: GroupVersion.Version, + Resource: "testresourceswithstatus", + } +} + +// IsStorageVersion returns true if the object is also the internal version -- i.e. is the type defined for the API group or an alias to this object. +// If false, the resource is expected to implement MultiVersionObject interface. +func (o *TestResourceWithStatus) IsStorageVersion() bool { + return true +} + +// NamespaceScoped returns true if the object is namespaced +func (o *TestResourceWithStatus) NamespaceScoped() bool { + return false +} + +// New returns a new instance of the resource +func (o *TestResourceWithStatus) New() runtime.Object { + return &TestResourceWithStatus{} +} + +// NewList return a new list instance of the resource +func (o *TestResourceWithStatus) NewList() runtime.Object { + return &TestResourceWithStatusList{} +} + +// GetStatus returns the status of the resource +func (o *TestResourceWithStatus) GetStatus() resource.StatusSubResource { + return &o.Status +} + +// CopyTo copies the status to the given parent resource +func (s *TestResourceWithStatusStatus) CopyTo(parent resource.ObjectWithStatusSubResource) { + parent.(*TestResourceWithStatus).Status = *s.DeepCopy() +} + +func (s TestResourceWithStatusStatus) SubResourceName() string { + return "status" +} + +// +kubebuilder:object:root=true +// TestResourceWithStatusList contains a list of TestResourceWithStatuss +type TestResourceWithStatusList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []TestResourceWithStatus `json:"items"` +} + +// TestResourceWithStatusList needs to implement the builder resource interface +var _ resource.ObjectList = &TestResourceWithStatusList{} + +// GetListMeta returns the list meta reference. +func (in *TestResourceWithStatusList) GetListMeta() *metav1.ListMeta { + return &in.ListMeta +} + +func init() { + SchemeBuilder.Register(&TestResourceWithStatus{}, &TestResourceWithStatusList{}) +} diff --git a/apiserver/testresource/with_status_test.go b/apiserver/testresource/with_status_test.go new file mode 100644 index 00000000..81da2f49 --- /dev/null +++ b/apiserver/testresource/with_status_test.go @@ -0,0 +1,14 @@ +package testresource + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStatus(t *testing.T) { + subject := &TestResourceWithStatus{} + (&TestResourceWithStatusStatus{Num: 7}).CopyTo(subject) + + require.Equal(t, 7, subject.GetStatus().(*TestResourceWithStatusStatus).Num) +} diff --git a/apiserver/testresource/zz_generated.deepcopy.go b/apiserver/testresource/zz_generated.deepcopy.go index 7a4a342f..97a47410 100644 --- a/apiserver/testresource/zz_generated.deepcopy.go +++ b/apiserver/testresource/zz_generated.deepcopy.go @@ -65,3 +65,76 @@ func (in *TestResourceList) DeepCopyObject() runtime.Object { } return nil } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestResourceWithStatus) DeepCopyInto(out *TestResourceWithStatus) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceWithStatus. +func (in *TestResourceWithStatus) DeepCopy() *TestResourceWithStatus { + if in == nil { + return nil + } + out := new(TestResourceWithStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TestResourceWithStatus) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestResourceWithStatusList) DeepCopyInto(out *TestResourceWithStatusList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TestResourceWithStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceWithStatusList. +func (in *TestResourceWithStatusList) DeepCopy() *TestResourceWithStatusList { + if in == nil { + return nil + } + out := new(TestResourceWithStatusList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TestResourceWithStatusList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestResourceWithStatusStatus) DeepCopyInto(out *TestResourceWithStatusStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceWithStatusStatus. +func (in *TestResourceWithStatusStatus) DeepCopy() *TestResourceWithStatusStatus { + if in == nil { + return nil + } + out := new(TestResourceWithStatusStatus) + in.DeepCopyInto(out) + return out +} diff --git a/apiserver/user/invitation_storage.go b/apiserver/user/invitation_storage.go new file mode 100644 index 00000000..8067c1c4 --- /dev/null +++ b/apiserver/user/invitation_storage.go @@ -0,0 +1,44 @@ +package user + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + genericregistry "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + restbuilder "sigs.k8s.io/apiserver-runtime/pkg/builder/rest" + "sigs.k8s.io/apiserver-runtime/pkg/util/loopback" + "sigs.k8s.io/controller-runtime/pkg/client" + + userv1 "github.com/appuio/control-api/apis/user/v1" + "github.com/appuio/control-api/apiserver/authwrapper" + "github.com/appuio/control-api/apiserver/secretstorage" +) + +// New returns a new storage provider with RBAC authentication for BillingEntities +func NewInvitationStorage(backingNS string) restbuilder.ResourceHandlerProvider { + return func(s *runtime.Scheme, g genericregistry.RESTOptionsGetter) (rest.Storage, error) { + c, err := client.NewWithWatch(loopback.GetLoopbackMasterClientConfig(), client.Options{}) + if err != nil { + return nil, err + } + err = userv1.AddToScheme(c.Scheme()) + if err != nil { + return nil, err + } + stor, err := secretstorage.NewStorage(&userv1.Invitation{}, c, backingNS) + if err != nil { + return nil, err + } + + astor, err := authwrapper.NewAuthorizedStorage(stor, metav1.GroupVersionResource{ + Group: "rbac.appuio.io", + Version: "v1", + Resource: (&userv1.Invitation{}).GetGroupVersionResource().Resource, + }, loopback.GetAuthorizer()) + if err != nil { + return nil, err + } + + return astor, nil + } +} From f99eb8fc846f3e6ef4d8d17dc557a185011e94b8 Mon Sep 17 00:00:00 2001 From: Sebastian Widmer Date: Mon, 13 Feb 2023 11:20:22 +0100 Subject: [PATCH 4/7] Create redemption token in Invitation.status --- api.go | 3 +- apis/user/v1/invitation_types.go | 16 +++-- apiserver/secretstorage/status.go | 7 +- apiserver/secretstorage/status/status.go | 20 ++++++ apiserver/secretstorage/status_test.go | 3 +- apiserver/secretstorage/storage.go | 16 +++-- apiserver/secretstorage/storage_test.go | 4 +- apiserver/testresource/with_status.go | 8 ++- apiserver/testresource/with_status_test.go | 4 +- config/rbac/controller/role.yaml | 32 +++++++++ controller.go | 17 ++++- controllers/invitation_token_controller.go | 66 +++++++++++++++++++ .../invitation_token_controller_test.go | 47 +++++++++++++ controllers/user_controller_test.go | 2 + 14 files changed, 218 insertions(+), 27 deletions(-) create mode 100644 apiserver/secretstorage/status/status.go create mode 100644 controllers/invitation_token_controller.go create mode 100644 controllers/invitation_token_controller_test.go diff --git a/api.go b/api.go index 6c1f2d68..650cbc2a 100644 --- a/api.go +++ b/api.go @@ -20,6 +20,7 @@ import ( billingStore "github.com/appuio/control-api/apiserver/billing" "github.com/appuio/control-api/apiserver/billing/odoostorage" orgStore "github.com/appuio/control-api/apiserver/organization" + "github.com/appuio/control-api/apiserver/secretstorage" "github.com/appuio/control-api/apiserver/user" ) @@ -38,7 +39,7 @@ func APICommand() *cobra.Command { WithResourceAndHandler(organizationStatusRegisterer{&orgv1.Organization{}}, ost). WithResourceAndHandler(&billingv1.BillingEntity{}, ob.Build). WithResourceAndHandler(&userv1.Invitation{}, ib.Build). - // WithResourceAndHandler(secretstorage.NewStatusSubResourceRegisterer(&userv1.Invitation{}), ib.Build). + WithResourceAndHandler(secretstorage.NewStatusSubResourceRegisterer(&userv1.Invitation{}), ib.Build). WithoutEtcd(). ExposeLoopbackAuthorizer(). ExposeLoopbackMasterClientConfig(). diff --git a/apis/user/v1/invitation_types.go b/apis/user/v1/invitation_types.go index b8adc24d..f4487e3d 100644 --- a/apis/user/v1/invitation_types.go +++ b/apis/user/v1/invitation_types.go @@ -5,6 +5,8 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/apiserver-runtime/pkg/builder/resource" + + "github.com/appuio/control-api/apiserver/secretstorage/status" ) const ( @@ -52,15 +54,15 @@ type TargetRef struct { // InvitationStatus defines the observed state of the Invitation type InvitationStatus struct { // Token is the invitation token - Token string `json:"token,omitempty"` + Token string `json:"token"` // ValidUntil is the time when the invitation expires - ValidUntil metav1.Time `json:"validUntil,omitempty"` + ValidUntil metav1.Time `json:"validUntil"` // Conditions is a list of conditions for the invitation - Conditions []metav1.Condition `json:"conditions,omitempty"` + Conditions []metav1.Condition `json:"conditions"` } // Invitation needs to implement the builder resource interface -var _ resource.ObjectWithStatusSubResource = &Invitation{} +var _ status.ObjectWithStatusSubResource = &Invitation{} // GetObjectMeta returns the objects meta reference. func (o *Invitation) GetObjectMeta() *metav1.ObjectMeta { @@ -98,13 +100,13 @@ func (o *Invitation) NewList() runtime.Object { return &InvitationList{} } -// GetStatus returns the status of the resource -func (o *Invitation) GetStatus() resource.StatusSubResource { +// SecretStorageGetStatus returns the status of the resource +func (o *Invitation) SecretStorageGetStatus() status.StatusSubResource { return &o.Status } // CopyTo copies the status to the given parent resource -func (s *InvitationStatus) CopyTo(parent resource.ObjectWithStatusSubResource) { +func (s *InvitationStatus) SecretStorageCopyTo(parent status.ObjectWithStatusSubResource) { parent.(*Invitation).Status = *s.DeepCopy() } diff --git a/apiserver/secretstorage/status.go b/apiserver/secretstorage/status.go index 46daee96..b5b62c34 100644 --- a/apiserver/secretstorage/status.go +++ b/apiserver/secretstorage/status.go @@ -4,7 +4,8 @@ import ( "fmt" "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/apiserver-runtime/pkg/builder/resource" + + "github.com/appuio/control-api/apiserver/secretstorage/status" ) // NewStatusSubResourceRegisterer returns a helper type to register a status subresource for a resource. @@ -12,12 +13,12 @@ import ( // builder.APIServer. // WithResourceAndHandler(&Resource{}, storage). // WithResourceAndHandler(StatusSubResourceRegisterer{&Resource{}}, storage). -func NewStatusSubResourceRegisterer(o resource.ObjectWithStatusSubResource) resource.ObjectWithStatusSubResource { +func NewStatusSubResourceRegisterer(o status.ObjectWithStatusSubResource) status.ObjectWithStatusSubResource { return statusSubResourceRegisterer{o} } type statusSubResourceRegisterer struct { - resource.ObjectWithStatusSubResource + status.ObjectWithStatusSubResource } func (o statusSubResourceRegisterer) GetGroupVersionResource() schema.GroupVersionResource { diff --git a/apiserver/secretstorage/status/status.go b/apiserver/secretstorage/status/status.go new file mode 100644 index 00000000..957d2508 --- /dev/null +++ b/apiserver/secretstorage/status/status.go @@ -0,0 +1,20 @@ +package status + +import "sigs.k8s.io/apiserver-runtime/pkg/builder/resource" + +// ObjectWithStatusSubResource defines an interface for getting and setting the status sub-resource for a resource. +// It is a copy of the interface in the apiserver-runtime package, but with the SecretStorage prefix. +// The prefix is needed since the apiserver-runtime package fails if the storage is not a k8s.io/apiserver/pkg/registry/generic/registry.Store. +type ObjectWithStatusSubResource interface { + resource.Object + + // SecretStorageGetStatus should return the status sub-resource for the resource. + SecretStorageGetStatus() (statusSubResource StatusSubResource) +} + +// StatusSubResource defines required methods for implementing a status subresource. +type StatusSubResource interface { + resource.SubResource + // SecretStorageCopyTo copies the content of the status subresource to a parent resource. + SecretStorageCopyTo(parent ObjectWithStatusSubResource) +} diff --git a/apiserver/secretstorage/status_test.go b/apiserver/secretstorage/status_test.go index 745c0a90..01c27ce0 100644 --- a/apiserver/secretstorage/status_test.go +++ b/apiserver/secretstorage/status_test.go @@ -3,8 +3,9 @@ package secretstorage import ( "testing" - "github.com/appuio/control-api/apiserver/testresource" "github.com/stretchr/testify/require" + + "github.com/appuio/control-api/apiserver/testresource" ) func TestStatusSubResourceRegisterer(t *testing.T) { diff --git a/apiserver/secretstorage/storage.go b/apiserver/secretstorage/storage.go index 89855a4d..4fb0cfe6 100644 --- a/apiserver/secretstorage/storage.go +++ b/apiserver/secretstorage/storage.go @@ -32,6 +32,8 @@ import ( "k8s.io/apiserver/pkg/server/storage" "sigs.k8s.io/apiserver-runtime/pkg/builder/resource" "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/appuio/control-api/apiserver/secretstorage/status" ) const ( @@ -301,9 +303,9 @@ func (s *secretStorage) create(ctx context.Context, obj runtime.Object, createVa // Add empty status if the object supports it // Status can only be modified through the status subresource and update calls - st, hasStatus := obj.(resource.ObjectWithStatusSubResource) + st, hasStatus := obj.(status.ObjectWithStatusSubResource) if hasStatus { - st.New().(resource.ObjectWithStatusSubResource).GetStatus().CopyTo(st) + st.New().(status.ObjectWithStatusSubResource).SecretStorageGetStatus().SecretStorageCopyTo(st) } if createValidation != nil { @@ -380,11 +382,11 @@ func objectPatch(serialized []byte) (client.Patch, error) { // filterStatusUpdates handles the status subresource if the object supports it func filterStatusUpdates(ctx context.Context, newObj, oldObj runtime.Object) (transformedNewObj runtime.Object, err error) { - oldWithStatus, ok := oldObj.(resource.ObjectWithStatusSubResource) + oldWithStatus, ok := oldObj.(status.ObjectWithStatusSubResource) if !ok { return newObj, nil } - newWithStatus, ok := newObj.(resource.ObjectWithStatusSubResource) + newWithStatus, ok := newObj.(status.ObjectWithStatusSubResource) if !ok { return newObj, nil } @@ -394,11 +396,11 @@ func filterStatusUpdates(ctx context.Context, newObj, oldObj runtime.Object) (tr } if requestInfo.Subresource == "status" { - withUpdatedStatus := oldWithStatus.DeepCopyObject().(resource.ObjectWithStatusSubResource) - newWithStatus.GetStatus().CopyTo(withUpdatedStatus) + withUpdatedStatus := oldWithStatus.DeepCopyObject().(status.ObjectWithStatusSubResource) + newWithStatus.SecretStorageGetStatus().SecretStorageCopyTo(withUpdatedStatus) return withUpdatedStatus, nil } // Status must be updated through the status subresource - oldWithStatus.GetStatus().CopyTo(newWithStatus) + oldWithStatus.SecretStorageGetStatus().SecretStorageCopyTo(newWithStatus) return newWithStatus, nil } diff --git a/apiserver/secretstorage/storage_test.go b/apiserver/secretstorage/storage_test.go index ca532f26..0c6a3dcc 100644 --- a/apiserver/secretstorage/storage_test.go +++ b/apiserver/secretstorage/storage_test.go @@ -94,7 +94,7 @@ func TestStatusSubresource(t *testing.T) { _, err = s.Create(context.Background(), ttr, nil, &metav1.CreateOptions{}) require.NoError(t, err) // Empty status on create, can only be set via the status subresource - require.Equal(t, 0, ttr.GetStatus().(*testresource.TestResourceWithStatusStatus).Num) + require.Equal(t, 0, ttr.SecretStorageGetStatus().(*testresource.TestResourceWithStatusStatus).Num) testStatusValue(t, s, 0) // Update the status @@ -119,7 +119,7 @@ func testStatusValue(t *testing.T, s rest.Getter, expected int) { ttr, err := s.Get(context.Background(), "test", &metav1.GetOptions{}) require.NoError(t, err) - require.Equal(t, expected, ttr.(*testresource.TestResourceWithStatus).GetStatus().(*testresource.TestResourceWithStatusStatus).Num) + require.Equal(t, expected, ttr.(*testresource.TestResourceWithStatus).SecretStorageGetStatus().(*testresource.TestResourceWithStatusStatus).Num) } func buildClient(t *testing.T, initObjs ...client.Object) client.WithWatch { diff --git a/apiserver/testresource/with_status.go b/apiserver/testresource/with_status.go index 8ad08b2e..20eaaa1b 100644 --- a/apiserver/testresource/with_status.go +++ b/apiserver/testresource/with_status.go @@ -8,6 +8,8 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/apiserver-runtime/pkg/builder/resource" + + "github.com/appuio/control-api/apiserver/secretstorage/status" ) // +kubebuilder:object:root=true @@ -26,7 +28,7 @@ type TestResourceWithStatusStatus struct { } // TestResourceWithStatus needs to implement the builder resource interface -var _ resource.ObjectWithStatusSubResource = &TestResourceWithStatus{} +var _ status.ObjectWithStatusSubResource = &TestResourceWithStatus{} // GetObjectMeta returns the objects meta reference. func (o *TestResourceWithStatus) GetObjectMeta() *metav1.ObjectMeta { @@ -65,12 +67,12 @@ func (o *TestResourceWithStatus) NewList() runtime.Object { } // GetStatus returns the status of the resource -func (o *TestResourceWithStatus) GetStatus() resource.StatusSubResource { +func (o *TestResourceWithStatus) SecretStorageGetStatus() status.StatusSubResource { return &o.Status } // CopyTo copies the status to the given parent resource -func (s *TestResourceWithStatusStatus) CopyTo(parent resource.ObjectWithStatusSubResource) { +func (s *TestResourceWithStatusStatus) SecretStorageCopyTo(parent status.ObjectWithStatusSubResource) { parent.(*TestResourceWithStatus).Status = *s.DeepCopy() } diff --git a/apiserver/testresource/with_status_test.go b/apiserver/testresource/with_status_test.go index 81da2f49..603fb80d 100644 --- a/apiserver/testresource/with_status_test.go +++ b/apiserver/testresource/with_status_test.go @@ -8,7 +8,7 @@ import ( func TestStatus(t *testing.T) { subject := &TestResourceWithStatus{} - (&TestResourceWithStatusStatus{Num: 7}).CopyTo(subject) + (&TestResourceWithStatusStatus{Num: 7}).SecretStorageCopyTo(subject) - require.Equal(t, 7, subject.GetStatus().(*TestResourceWithStatusStatus).Num) + require.Equal(t, 7, subject.SecretStorageGetStatus().(*TestResourceWithStatusStatus).Num) } diff --git a/config/rbac/controller/role.yaml b/config/rbac/controller/role.yaml index 9307e579..5ffe7453 100644 --- a/config/rbac/controller/role.yaml +++ b/config/rbac/controller/role.yaml @@ -98,6 +98,22 @@ rules: - get - list - watch +- apiGroups: + - rbac.appuio.io + resources: + - invitations + verbs: + - get + - list + - watch +- apiGroups: + - rbac.appuio.io + resources: + - invitations/status + verbs: + - get + - patch + - update - apiGroups: - rbac.appuio.io resources: @@ -142,3 +158,19 @@ rules: - patch - update - watch +- apiGroups: + - user.appuio.io + resources: + - invitations + verbs: + - get + - list + - watch +- apiGroups: + - user.appuio.io + resources: + - invitations/status + verbs: + - get + - patch + - update diff --git a/controller.go b/controller.go index 195f27a9..74318047 100644 --- a/controller.go +++ b/controller.go @@ -21,6 +21,7 @@ import ( billingv1 "github.com/appuio/control-api/apis/billing/v1" orgv1 "github.com/appuio/control-api/apis/organization/v1" + userv1 "github.com/appuio/control-api/apis/user/v1" controlv1 "github.com/appuio/control-api/apis/v1" "github.com/appuio/control-api/controllers" @@ -53,6 +54,8 @@ func ControllerCommand() *cobra.Command { beRefreshInterval := cmd.Flags().Duration("billing-entity-refresh-interval", 5*time.Minute, "The interval at which the billing entity cache is refreshed") beRefreshJitter := cmd.Flags().Duration("billing-entity-refresh-jitter", time.Minute, "The jitter added to the interval at which the billing entity cache is refreshed") + invTokenValidFor := cmd.Flags().Duration("invitation-valid-for", 30*24*time.Hour, "The duration an invitation token is valid for") + cmd.Run = func(*cobra.Command, []string) { scheme := runtime.NewScheme() setupLog := ctrl.Log.WithName("setup") @@ -61,6 +64,7 @@ func ControllerCommand() *cobra.Command { utilruntime.Must(orgv1.AddToScheme(scheme)) utilruntime.Must(controlv1.AddToScheme(scheme)) utilruntime.Must(billingv1.AddToScheme(scheme)) + utilruntime.Must(userv1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) @@ -72,6 +76,7 @@ func ControllerCommand() *cobra.Command { *memberRoles, *beRefreshInterval, *beRefreshJitter, + *invTokenValidFor, ctrl.Options{ Scheme: scheme, MetricsBindAddress: *metricsAddr, @@ -96,7 +101,7 @@ func ControllerCommand() *cobra.Command { return cmd } -func setupManager(usernamePrefix, rolePrefix string, memberRoles []string, beRefreshInterval, beRefreshJitter time.Duration, opt ctrl.Options) (ctrl.Manager, error) { +func setupManager(usernamePrefix, rolePrefix string, memberRoles []string, beRefreshInterval, beRefreshJitter, invTokenValidFor time.Duration, opt ctrl.Options) (ctrl.Manager, error) { mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), opt) if err != nil { return nil, err @@ -137,6 +142,16 @@ func setupManager(usernamePrefix, rolePrefix string, memberRoles []string, beRef if err = obenc.SetupWithManager(mgr); err != nil { return nil, err } + invtoc := &controllers.InvitationTokenReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("invitation-token-controller"), + + TokenValidFor: invTokenValidFor, + } + if err = invtoc.SetupWithManager(mgr); err != nil { + return nil, err + } mgr.GetWebhookServer().Register("/validate-appuio-io-v1-user", &webhook.Admission{ Handler: &webhooks.UserValidator{}, diff --git a/controllers/invitation_token_controller.go b/controllers/invitation_token_controller.go new file mode 100644 index 00000000..00d64612 --- /dev/null +++ b/controllers/invitation_token_controller.go @@ -0,0 +1,66 @@ +package controllers + +import ( + "context" + "time" + + "github.com/google/uuid" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + userv1 "github.com/appuio/control-api/apis/user/v1" +) + +// InvitationTokenReconciler reconciles a User object +type InvitationTokenReconciler struct { + client.Client + + Recorder record.EventRecorder + Scheme *runtime.Scheme + + TokenValidFor time.Duration +} + +//+kubebuilder:rbac:groups="rbac.appuio.io",resources=invitations,verbs=get;list;watch +//+kubebuilder:rbac:groups="user.appuio.io",resources=invitations,verbs=get;list;watch +//+kubebuilder:rbac:groups="rbac.appuio.io",resources=invitations/status,verbs=get;update;patch +//+kubebuilder:rbac:groups="user.appuio.io",resources=invitations/status,verbs=get;update;patch + +// Reconcile reacts on invitations and adds a token to the status if required. +func (r *InvitationTokenReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + log.V(4).WithValues("request", req).Info("Reconciling") + + log.V(4).Info("Getting the User...") + inv := userv1.Invitation{} + if err := r.Get(ctx, req.NamespacedName, &inv); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if !inv.ObjectMeta.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + if inv.Status.Token != "" { + return ctrl.Result{}, nil + } + + inv.Status.Token = uuid.New().String() + inv.Status.ValidUntil = metav1.NewTime(time.Now().Add(r.TokenValidFor)) + if err := r.Status().Update(ctx, &inv); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *InvitationTokenReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&userv1.Invitation{}). + Complete(r) +} diff --git a/controllers/invitation_token_controller_test.go b/controllers/invitation_token_controller_test.go new file mode 100644 index 00000000..86475c58 --- /dev/null +++ b/controllers/invitation_token_controller_test.go @@ -0,0 +1,47 @@ +package controllers_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + + userv1 "github.com/appuio/control-api/apis/user/v1" + . "github.com/appuio/control-api/controllers" +) + +func Test_InvitationTokenReconciler_Reconcile_Success(t *testing.T) { + const tokenValidFor = time.Minute + ctx := context.Background() + + subject := userv1.Invitation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "subject", + }, + } + + c := prepareTest(t, &subject) + + _, err := (&InvitationTokenReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: record.NewFakeRecorder(3), + + TokenValidFor: tokenValidFor, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: subject.Name, + }, + }) + require.NoError(t, err) + + require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject)) + assert.NotEmpty(t, subject.Status.Token) + assert.WithinDuration(t, time.Now().Add(tokenValidFor), subject.Status.ValidUntil.Time, time.Second) +} diff --git a/controllers/user_controller_test.go b/controllers/user_controller_test.go index d7a873ce..4326d4ca 100644 --- a/controllers/user_controller_test.go +++ b/controllers/user_controller_test.go @@ -19,6 +19,7 @@ import ( billingv1 "github.com/appuio/control-api/apis/billing/v1" orgv1 "github.com/appuio/control-api/apis/organization/v1" + userv1 "github.com/appuio/control-api/apis/user/v1" controlv1 "github.com/appuio/control-api/apis/v1" . "github.com/appuio/control-api/controllers" ) @@ -73,6 +74,7 @@ func prepareTest(t *testing.T, initObjs ...client.Object) client.WithWatch { utilruntime.Must(orgv1.AddToScheme(scheme)) utilruntime.Must(controlv1.AddToScheme(scheme)) utilruntime.Must(billingv1.AddToScheme(scheme)) + utilruntime.Must(userv1.AddToScheme(scheme)) return fake.NewClientBuilder(). WithScheme(scheme). From 72c593ae69895dfc9e071e00688089788c0c464a Mon Sep 17 00:00:00 2001 From: Sebastian Widmer Date: Tue, 14 Feb 2023 09:02:12 +0100 Subject: [PATCH 5/7] Move common controller setup method to its own file --- controllers/common_test.go | 30 +++++++++++++++++++++++++++++ controllers/user_controller_test.go | 22 --------------------- 2 files changed, 30 insertions(+), 22 deletions(-) create mode 100644 controllers/common_test.go diff --git a/controllers/common_test.go b/controllers/common_test.go new file mode 100644 index 00000000..bc8ed016 --- /dev/null +++ b/controllers/common_test.go @@ -0,0 +1,30 @@ +package controllers_test + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + billingv1 "github.com/appuio/control-api/apis/billing/v1" + orgv1 "github.com/appuio/control-api/apis/organization/v1" + userv1 "github.com/appuio/control-api/apis/user/v1" + controlv1 "github.com/appuio/control-api/apis/v1" +) + +func prepareTest(t *testing.T, initObjs ...client.Object) client.WithWatch { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(orgv1.AddToScheme(scheme)) + utilruntime.Must(controlv1.AddToScheme(scheme)) + utilruntime.Must(billingv1.AddToScheme(scheme)) + utilruntime.Must(userv1.AddToScheme(scheme)) + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(initObjs...). + Build() +} diff --git a/controllers/user_controller_test.go b/controllers/user_controller_test.go index 4326d4ca..9bf78b80 100644 --- a/controllers/user_controller_test.go +++ b/controllers/user_controller_test.go @@ -8,18 +8,10 @@ import ( "github.com/stretchr/testify/require" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - billingv1 "github.com/appuio/control-api/apis/billing/v1" - orgv1 "github.com/appuio/control-api/apis/organization/v1" - userv1 "github.com/appuio/control-api/apis/user/v1" controlv1 "github.com/appuio/control-api/apis/v1" . "github.com/appuio/control-api/controllers" ) @@ -67,17 +59,3 @@ func Test_UserController_Reconcile_Success(t *testing.T) { assert.Len(t, crb.Subjects, 1, "ClusterRoleBinding should have subject referencing the user") assert.Equal(t, userPrefix+subject.Name, crb.Subjects[0].Name, "user name must match and include prefix") } - -func prepareTest(t *testing.T, initObjs ...client.Object) client.WithWatch { - scheme := runtime.NewScheme() - utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(orgv1.AddToScheme(scheme)) - utilruntime.Must(controlv1.AddToScheme(scheme)) - utilruntime.Must(billingv1.AddToScheme(scheme)) - utilruntime.Must(userv1.AddToScheme(scheme)) - - return fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(initObjs...). - Build() -} From 9c884cceb88c4ee9313a4e903e609ea0b9434a8a Mon Sep 17 00:00:00 2001 From: Sebastian Widmer Date: Tue, 14 Feb 2023 09:28:45 +0100 Subject: [PATCH 6/7] fix typo --- controllers/invitation_token_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/invitation_token_controller.go b/controllers/invitation_token_controller.go index 00d64612..8f923a72 100644 --- a/controllers/invitation_token_controller.go +++ b/controllers/invitation_token_controller.go @@ -15,7 +15,7 @@ import ( userv1 "github.com/appuio/control-api/apis/user/v1" ) -// InvitationTokenReconciler reconciles a User object +// InvitationTokenReconciler reconciles invitations and adds a token to the status if required. type InvitationTokenReconciler struct { client.Client From 2aff93a6e0a82a2ce6055f12bf0bc17dd29d04b8 Mon Sep 17 00:00:00 2001 From: Sebastian Widmer Date: Tue, 14 Feb 2023 09:37:39 +0100 Subject: [PATCH 7/7] Allow apiserver to access secrets --- apiserver/secretstorage/storage.go | 2 ++ config/rbac/apiserver/role.yaml | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/apiserver/secretstorage/storage.go b/apiserver/secretstorage/storage.go index 4fb0cfe6..a5db42e9 100644 --- a/apiserver/secretstorage/storage.go +++ b/apiserver/secretstorage/storage.go @@ -36,6 +36,8 @@ import ( "github.com/appuio/control-api/apiserver/secretstorage/status" ) +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete + const ( secretObjectKey = "object" ) diff --git a/config/rbac/apiserver/role.yaml b/config/rbac/apiserver/role.yaml index cd97929a..0477aec1 100644 --- a/config/rbac/apiserver/role.yaml +++ b/config/rbac/apiserver/role.yaml @@ -34,6 +34,18 @@ rules: - list - update - watch +- apiGroups: + - "" + resources: + - secrets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - admissionregistration.k8s.io resources: