-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #116 from appuio/feat/invitation-emails
Add controller for invitation e-mail sending
- Loading branch information
Showing
6 changed files
with
358 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -24,6 +24,7 @@ import ( | |
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/mailsenders" | ||
|
||
"github.com/appuio/control-api/controllers" | ||
"github.com/appuio/control-api/webhooks" | ||
|
@@ -58,6 +59,17 @@ func ControllerCommand() *cobra.Command { | |
invTokenValidFor := cmd.Flags().Duration("invitation-valid-for", 30*24*time.Hour, "The duration an invitation token is valid for") | ||
redeemedInvitationTTL := cmd.Flags().Duration("redeemed-invitation-ttl", 30*24*time.Hour, "The duration for which a redeemed invitation is kept before deleting it") | ||
|
||
invEmailBackend := cmd.Flags().String("email-backend", "stdout", "Backend to use for sending invitation mails (one of stdout, mailgun)") | ||
invEmailSender := cmd.Flags().String("email-sender", "[email protected]", "Sender address for invitation mails") | ||
invEmailSubject := cmd.Flags().String("email-subject", "You have been invited to APPUiO Cloud", "Subject for invitation mails") | ||
invEmailRetryInterval := cmd.Flags().Duration("email-retry-interval", 5*time.Minute, "Retry interval for sending e-mail messages") | ||
|
||
invEmailMailgunToken := cmd.Flags().String("mailgun-token", "CHANGEME", "Token used to access Mailgun API") | ||
invEmailMailgunDomain := cmd.Flags().String("mailgun-domain", "example.com", "Mailgun Domain to use") | ||
invEmailMailgunTemplate := cmd.Flags().String("mailgun-template", "appuio-cloud-invitation", "Name of the Mailgun template") | ||
invEmailMailgunUrl := cmd.Flags().String("mailgun-url", "https://api.eu.mailgun.net/v3", "API base URL for your Mailgun account") | ||
invEmailMailgunTestMode := cmd.Flags().Bool("mailgun-test-mode", false, "If set, do not actually send e-mails") | ||
|
||
cmd.Run = func(*cobra.Command, []string) { | ||
scheme := runtime.NewScheme() | ||
setupLog := ctrl.Log.WithName("setup") | ||
|
@@ -72,6 +84,22 @@ func ControllerCommand() *cobra.Command { | |
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) | ||
ctx := ctrl.SetupSignalHandler() | ||
|
||
var mailSender mailsenders.MailSender | ||
if *invEmailBackend == "mailgun" { | ||
b := mailsenders.NewMailgunSender( | ||
*invEmailMailgunDomain, | ||
*invEmailMailgunToken, | ||
*invEmailMailgunUrl, | ||
*invEmailSender, | ||
*invEmailMailgunTemplate, | ||
*invEmailSubject, | ||
*invEmailMailgunTestMode, | ||
) | ||
mailSender = &b | ||
} else { | ||
mailSender = &mailsenders.LogSender{} | ||
} | ||
|
||
mgr, err := setupManager( | ||
*usernamePrefix, | ||
*rolePrefix, | ||
|
@@ -80,6 +108,8 @@ func ControllerCommand() *cobra.Command { | |
*beRefreshJitter, | ||
*invTokenValidFor, | ||
*redeemedInvitationTTL, | ||
*invEmailRetryInterval, | ||
mailSender, | ||
ctrl.Options{ | ||
Scheme: scheme, | ||
MetricsBindAddress: *metricsAddr, | ||
|
@@ -104,7 +134,18 @@ func ControllerCommand() *cobra.Command { | |
return cmd | ||
} | ||
|
||
func setupManager(usernamePrefix, rolePrefix string, memberRoles []string, beRefreshInterval, beRefreshJitter, invTokenValidFor time.Duration, redeemedInvitationTTL time.Duration, opt ctrl.Options) (ctrl.Manager, error) { | ||
func setupManager( | ||
usernamePrefix, | ||
rolePrefix string, | ||
memberRoles []string, | ||
beRefreshInterval, | ||
beRefreshJitter, | ||
invTokenValidFor time.Duration, | ||
redeemedInvitationTTL time.Duration, | ||
invEmailRetryInterval time.Duration, | ||
mailSender mailsenders.MailSender, | ||
opt ctrl.Options, | ||
) (ctrl.Manager, error) { | ||
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), opt) | ||
if err != nil { | ||
return nil, err | ||
|
@@ -177,6 +218,16 @@ func setupManager(usernamePrefix, rolePrefix string, memberRoles []string, beRef | |
if err = invclean.SetupWithManager(mgr); err != nil { | ||
return nil, err | ||
} | ||
invmail := &controllers.InvitationEmailReconciler{ | ||
Client: mgr.GetClient(), | ||
Scheme: mgr.GetScheme(), | ||
Recorder: mgr.GetEventRecorderFor("invitation-cleanup-controller"), | ||
RetryInterval: invEmailRetryInterval, | ||
MailSender: mailSender, | ||
} | ||
if err = invmail.SetupWithManager(mgr); err != nil { | ||
return nil, err | ||
} | ||
|
||
mgr.GetWebhookServer().Register("/validate-appuio-io-v1-user", &webhook.Admission{ | ||
Handler: &webhooks.UserValidator{}, | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
package controllers | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"time" | ||
|
||
apimeta "k8s.io/apimachinery/pkg/api/meta" | ||
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" | ||
|
||
"github.com/appuio/control-api/mailsenders" | ||
|
||
userv1 "github.com/appuio/control-api/apis/user/v1" | ||
) | ||
|
||
// InvitationEmailReconciler reconciles invitations and sends invitation emails if appropriate | ||
type InvitationEmailReconciler struct { | ||
client.Client | ||
|
||
Recorder record.EventRecorder | ||
Scheme *runtime.Scheme | ||
|
||
MailSender mailsenders.MailSender | ||
RetryInterval 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 to redeemed invitations and sends invitation emails to the user if needed. | ||
func (r *InvitationEmailReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { | ||
log := log.FromContext(ctx) | ||
log.V(1).WithValues("request", req).Info("Reconciling") | ||
|
||
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 | ||
} | ||
|
||
if apimeta.IsStatusConditionTrue(inv.Status.Conditions, userv1.ConditionEmailSent) { | ||
return ctrl.Result{}, nil | ||
} | ||
condition := apimeta.FindStatusCondition(inv.Status.Conditions, userv1.ConditionEmailSent) | ||
|
||
if condition == nil { | ||
return ctrl.Result{}, nil | ||
} | ||
|
||
email := inv.Spec.Email | ||
id, err := r.MailSender.Send(ctx, email, inv.Name, inv.Status.Token) | ||
|
||
if err != nil { | ||
log.V(0).Error(err, "Error in e-mail backend") | ||
|
||
// Only update status if the error changes | ||
if condition.Reason != err.Error() { | ||
apimeta.SetStatusCondition(&inv.Status.Conditions, metav1.Condition{ | ||
Type: userv1.ConditionEmailSent, | ||
Status: metav1.ConditionFalse, | ||
Reason: err.Error(), | ||
}) | ||
return ctrl.Result{}, r.Client.Status().Update(ctx, &inv) | ||
} | ||
return ctrl.Result{RequeueAfter: r.RetryInterval}, nil | ||
} | ||
|
||
var message string | ||
if id != "" { | ||
message = fmt.Sprintf("Message ID: %s", id) | ||
} | ||
apimeta.SetStatusCondition(&inv.Status.Conditions, metav1.Condition{ | ||
Type: userv1.ConditionEmailSent, | ||
Status: metav1.ConditionTrue, | ||
Message: message, | ||
}) | ||
|
||
return ctrl.Result{}, r.Client.Status().Update(ctx, &inv) | ||
} | ||
|
||
// SetupWithManager sets up the controller with the Manager. | ||
func (r *InvitationEmailReconciler) SetupWithManager(mgr ctrl.Manager) error { | ||
return ctrl.NewControllerManagedBy(mgr). | ||
For(&userv1.Invitation{}). | ||
Complete(r) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
package controllers_test | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"testing" | ||
"time" | ||
|
||
"github.com/stretchr/testify/require" | ||
apimeta "k8s.io/apimachinery/pkg/api/meta" | ||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
"k8s.io/client-go/tools/record" | ||
"sigs.k8s.io/controller-runtime/pkg/client" | ||
|
||
userv1 "github.com/appuio/control-api/apis/user/v1" | ||
. "github.com/appuio/control-api/controllers" | ||
) | ||
|
||
type FailingSender struct{} | ||
type SenderWithConstantId struct{} | ||
|
||
func (f *FailingSender) Send(context.Context, string, string, string) (string, error) { | ||
return "", errors.New("Err0r") | ||
} | ||
|
||
func (s *SenderWithConstantId) Send(context.Context, string, string, string) (string, error) { | ||
return "ID10", nil | ||
} | ||
|
||
func Test_InvitationEmailReconciler_Reconcile_Success(t *testing.T) { | ||
ctx := context.Background() | ||
|
||
subject := &userv1.Invitation{ | ||
ObjectMeta: metav1.ObjectMeta{ | ||
Name: "subject", | ||
}, | ||
Status: userv1.InvitationStatus{ | ||
Token: "abc", | ||
}, | ||
} | ||
apimeta.SetStatusCondition(&subject.Status.Conditions, metav1.Condition{ | ||
Type: userv1.ConditionEmailSent, | ||
Status: metav1.ConditionFalse, | ||
}) | ||
|
||
c := prepareTest(t, subject) | ||
|
||
r := invitationEmailReconciler(c) | ||
_, err := r.Reconcile(ctx, requestFor(subject)) | ||
require.NoError(t, err) | ||
|
||
require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(subject), subject)) | ||
require.True(t, apimeta.IsStatusConditionTrue(subject.Status.Conditions, userv1.ConditionEmailSent)) | ||
condition := apimeta.FindStatusCondition(subject.Status.Conditions, userv1.ConditionEmailSent) | ||
require.Equal(t, "Message ID: ID10", condition.Message) | ||
} | ||
|
||
func Test_InvitationEmailReconciler_Reconcile_WithSendingFailure_Success(t *testing.T) { | ||
ctx := context.Background() | ||
|
||
subject := &userv1.Invitation{ | ||
ObjectMeta: metav1.ObjectMeta{ | ||
Name: "subject", | ||
}, | ||
Status: userv1.InvitationStatus{ | ||
Token: "abc", | ||
}, | ||
} | ||
apimeta.SetStatusCondition(&subject.Status.Conditions, metav1.Condition{ | ||
Type: userv1.ConditionEmailSent, | ||
Status: metav1.ConditionFalse, | ||
}) | ||
|
||
c := prepareTest(t, subject) | ||
|
||
r := invitationEmailReconcilerWithFailingSender(c) | ||
_, err := r.Reconcile(ctx, requestFor(subject)) | ||
require.NoError(t, err) | ||
|
||
require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(subject), subject)) | ||
require.False(t, apimeta.IsStatusConditionTrue(subject.Status.Conditions, userv1.ConditionEmailSent)) | ||
condition := apimeta.FindStatusCondition(subject.Status.Conditions, userv1.ConditionEmailSent) | ||
require.NotNil(t, condition) | ||
require.Equal(t, "Err0r", condition.Reason) | ||
} | ||
|
||
func Test_InvitationEmailReconciler_Reconcile_NoEmail_Success(t *testing.T) { | ||
ctx := context.Background() | ||
|
||
subject := &userv1.Invitation{ | ||
ObjectMeta: metav1.ObjectMeta{ | ||
Name: "subject", | ||
}, | ||
Status: userv1.InvitationStatus{ | ||
Token: "abc", | ||
}, | ||
} | ||
|
||
c := prepareTest(t, subject) | ||
|
||
r := invitationEmailReconciler(c) | ||
_, err := r.Reconcile(ctx, requestFor(subject)) | ||
require.NoError(t, err) | ||
|
||
require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(subject), subject)) | ||
require.Nil(t, apimeta.FindStatusCondition(subject.Status.Conditions, userv1.ConditionEmailSent)) | ||
} | ||
|
||
func invitationEmailReconciler(c client.WithWatch) *InvitationEmailReconciler { | ||
return &InvitationEmailReconciler{ | ||
Client: c, | ||
Scheme: c.Scheme(), | ||
Recorder: record.NewFakeRecorder(3), | ||
MailSender: &SenderWithConstantId{}, | ||
RetryInterval: time.Minute, | ||
} | ||
} | ||
|
||
func invitationEmailReconcilerWithFailingSender(c client.WithWatch) *InvitationEmailReconciler { | ||
return &InvitationEmailReconciler{ | ||
Client: c, | ||
Scheme: c.Scheme(), | ||
Recorder: record.NewFakeRecorder(3), | ||
MailSender: &FailingSender{}, | ||
RetryInterval: time.Minute, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.