Skip to content

Commit 55343ff

Browse files
author
Aline Abler
committed
Implement email notifications on BillingEntity update
1 parent b98b98c commit 55343ff

File tree

16 files changed

+457
-60
lines changed

16 files changed

+457
-60
lines changed

apis/billing/v1/billingentity_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import (
77
"sigs.k8s.io/apiserver-runtime/pkg/builder/resource"
88
)
99

10+
const (
11+
// ConditionEmailSent is set when the update notification email has been sent
12+
ConditionEmailSent = "EmailSent"
13+
ConditionReasonSendFailed = "SendFailed"
14+
ConditionReasonUpdated = "Updated"
15+
)
16+
1017
// +kubebuilder:object:root=true
1118

1219
// BillingEntity is a representation of an APPUiO Cloud BillingEntity

apis/user/v1/invitation_types.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ const (
1616
// ConditionRedeemed is set when the invitation has been redeemed
1717
ConditionRedeemed = "Redeemed"
1818
// ConditionEmailSent is set when the invitation email has been sent
19-
ConditionEmailSent = "EmailSent"
19+
ConditionEmailSent = "EmailSent"
20+
ConditionReasonSendFailed = "SendFailed"
2021
)
2122

2223
// +kubebuilder:object:root=true

apiserver/billing/odoostorage/odoo/odoo8/odoo8.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ var (
4848

4949
"email",
5050
"phone",
51-
"x_control_api_meta_status",
5251
)
5352
accountingContactUpdateAllowedFields = newSet(
5453
"x_invoice_contact",
54+
"x_control_api_meta_status",
5555
"email",
5656
)
5757
)
@@ -291,10 +291,12 @@ func mapPartnersToBillingEntity(ctx context.Context, company model.Partner, acco
291291
name := odooIDToK8sID(accounting.ID)
292292

293293
var status billingv1.BillingEntityStatus
294-
err := json.Unmarshal([]byte(accounting.Status.Value), &status)
294+
if accounting.Status.Value != "" {
295+
err := json.Unmarshal([]byte(accounting.Status.Value), &status)
295296

296-
if err != nil {
297-
l.Error(err, "Could not unmarshal BillingEntityStatus", "billingEntityName", name, "rawStatus", accounting.Status.Value)
297+
if err != nil {
298+
l.Error(err, "Could not unmarshal BillingEntityStatus", "billingEntityName", name, "rawStatus", accounting.Status.Value)
299+
}
298300
}
299301

300302
return billingv1.BillingEntity{

apiserver/billing/odoostorage/update.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package odoostorage
33
import (
44
"context"
55
"fmt"
6+
"reflect"
67

8+
apimeta "k8s.io/apimachinery/pkg/api/meta"
79
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
810
"k8s.io/apimachinery/pkg/runtime"
911
"k8s.io/apiserver/pkg/registry/rest"
@@ -38,5 +40,13 @@ func (s *billingEntityStorage) Update(ctx context.Context, name string, objInfo
3840
}
3941
}
4042

43+
if !reflect.DeepEqual(newBE.Spec, oldBE.Spec) {
44+
apimeta.SetStatusCondition(&newBE.Status.Conditions, metav1.Condition{
45+
Status: metav1.ConditionFalse,
46+
Type: billingv1.ConditionEmailSent,
47+
Reason: billingv1.ConditionReasonUpdated,
48+
})
49+
}
50+
4151
return newBE, false, s.storage.Update(ctx, newBE)
4252
}

config/rbac/controller/role.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ rules:
115115
- get
116116
- list
117117
- watch
118+
- apiGroups:
119+
- rbac.appuio.io
120+
resources:
121+
- billingentities/status
122+
verbs:
123+
- get
124+
- patch
125+
- update
118126
- apiGroups:
119127
- rbac.appuio.io
120128
resources:
@@ -189,6 +197,21 @@ rules:
189197
- patch
190198
- update
191199
- watch
200+
- apiGroups:
201+
- user.appuio.io
202+
resources:
203+
- billingentities
204+
verbs:
205+
- get
206+
- list
207+
- apiGroups:
208+
- user.appuio.io
209+
resources:
210+
- billingentities/status
211+
verbs:
212+
- get
213+
- patch
214+
- update
192215
- apiGroups:
193216
- user.appuio.io
194217
resources:

controller.go

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"context"
45
"flag"
56
"os"
67
"text/template"
@@ -10,6 +11,7 @@ import (
1011
// to ensure that exec-entrypoint and run can make use of them.
1112
_ "k8s.io/client-go/plugin/pkg/client/auth"
1213

14+
"github.com/Masterminds/sprig/v3"
1315
"k8s.io/apimachinery/pkg/runtime"
1416
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
1517
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
@@ -19,6 +21,7 @@ import (
1921
"sigs.k8s.io/controller-runtime/pkg/metrics"
2022
"sigs.k8s.io/controller-runtime/pkg/webhook"
2123

24+
"github.com/robfig/cron/v3"
2225
"github.com/spf13/cobra"
2326

2427
billingv1 "github.com/appuio/control-api/apis/billing/v1"
@@ -35,14 +38,22 @@ import (
3538
const (
3639
defaultInvitationEmailTemplate = `Hello developer of great software, Kubernetes engineer or fellow human,
3740
38-
A user of APPUiO Cloud has invited you to join them. Follow https://portal.dev/invitations/{{.Invitation.ObjectMeta.Name}}?token={{.Invitation.Status.Token}} to accept this invitation.
41+
A user of APPUiO Cloud has invited you to join them. Follow https://portal.dev/invitations/{{.Object.ObjectMeta.Name}}?token={{.Object.Status.Token}} to accept this invitation.
3942
4043
APPUiO Cloud is a shared Kubernetes offering based on OpenShift provided by https://vshn.ch.
4144
4245
Unsure what to do next? Accept this invitation using the link above, login to one of the zones listed at https://portal.appuio.cloud/zones, deploy your application. A getting started guide on how to do so, is available at https://docs.appuio.cloud/user/tutorials/getting-started.html. To learn more about APPUiO Cloud in general, please visit https://appuio.cloud.
4346
4447
If you have any problems or questions, please email us at [email protected].
4548
49+
All the best
50+
Your APPUiO Cloud Team`
51+
defaultBillingEntityEmailTemplate = `Good time of day!
52+
53+
A user of APPUiO Cloud has updated billing entity {{.Object.ObjectMeta.Name}} ({{.Object.Spec.Name}}).
54+
55+
See https://erp.vshn.net/web#id={{ trimPrefix "be-" .Object.ObjectMeta.Name }}&view_type=form&model=res.partner&menu_id=74&action=60 for details.
56+
4657
All the best
4758
Your APPUiO Cloud Team`
4859
)
@@ -86,6 +97,11 @@ func ControllerCommand() *cobra.Command {
8697
invEmailMailgunUrl := cmd.Flags().String("mailgun-url", "https://api.eu.mailgun.net/v3", "API base URL for your Mailgun account")
8798
invEmailMailgunTestMode := cmd.Flags().Bool("mailgun-test-mode", false, "If set, do not actually send e-mails")
8899

100+
billingEntityEmailBodyTemplate := cmd.Flags().String("billingentity-email-body-template", defaultBillingEntityEmailTemplate, "Body for billing entity modification update mails")
101+
billingEntityEmailRecipient := cmd.Flags().String("billingentity-email-recipient", "", "Recipient e-mail address for billing entity modification update mails")
102+
billingEntityEmailSubject := cmd.Flags().String("billingentity-email-subject", "An APPUiO Billing Entity has been updated", "Subject for billing entity modification update mails")
103+
billingEntityCronInterval := cmd.Flags().String("billingentity-email-cron-interval", "@every 1m", "Cron interval for how frequently billing entity update e-mails are sent")
104+
89105
cmd.Run = func(*cobra.Command, []string) {
90106
scheme := runtime.NewScheme()
91107
setupLog := ctrl.Log.WithName("setup")
@@ -100,29 +116,59 @@ func ControllerCommand() *cobra.Command {
100116
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
101117
ctx := ctrl.SetupSignalHandler()
102118

103-
bt, err := template.New("emailBody").Parse(*emailBodyTemplate)
119+
bt, err := template.New("emailBody").Funcs(sprig.FuncMap()).Parse(*emailBodyTemplate)
104120
if err != nil {
105-
setupLog.Error(err, "Failed to parse email body template")
121+
setupLog.Error(err, "Failed to parse email body template for invitations")
106122
os.Exit(1)
107123
}
108-
bodyRenderer := &mailsenders.InvitationRenderer{Template: bt}
124+
bet, err := template.New("emailBody").Funcs(sprig.FuncMap()).Parse(*billingEntityEmailBodyTemplate)
125+
if err != nil {
126+
setupLog.Error(err, "Failed to parse email body template for billing entity e-mails")
127+
os.Exit(1)
128+
}
129+
invitationBodyRenderer := &mailsenders.Renderer{Template: bt}
130+
billingEntityBodyRenderer := &mailsenders.Renderer{Template: bet}
109131

110-
var mailSender mailsenders.MailSender
132+
var invMailSender mailsenders.MailSender
133+
var beMailSender mailsenders.MailSender
111134
if *invEmailBackend == "mailgun" {
112135
b := mailsenders.NewMailgunSender(
113136
*invEmailMailgunDomain,
114137
*invEmailMailgunToken,
115138
*invEmailMailgunUrl,
116139
*invEmailSender,
117-
bodyRenderer,
140+
invitationBodyRenderer,
118141
*invEmailSubject,
119142
*invEmailMailgunTestMode,
120143
)
121-
mailSender = &b
144+
invMailSender = &b
145+
if *billingEntityEmailRecipient != "" {
146+
be := mailsenders.NewMailgunSender(
147+
*invEmailMailgunDomain,
148+
*invEmailMailgunToken,
149+
*invEmailMailgunUrl,
150+
*invEmailSender,
151+
billingEntityBodyRenderer,
152+
*billingEntityEmailSubject,
153+
*invEmailMailgunTestMode,
154+
)
155+
beMailSender = &be
156+
} else {
157+
// fall back to stdout if no recipient e-mail is given
158+
beMailSender = &mailsenders.StdoutSender{
159+
Subject: *billingEntityEmailSubject,
160+
Body: billingEntityBodyRenderer,
161+
}
162+
}
163+
invMailSender = &b
122164
} else {
123-
mailSender = &mailsenders.StdoutSender{
165+
invMailSender = &mailsenders.StdoutSender{
124166
Subject: *invEmailSubject,
125-
Body: bodyRenderer,
167+
Body: invitationBodyRenderer,
168+
}
169+
beMailSender = &mailsenders.StdoutSender{
170+
Subject: *billingEntityEmailSubject,
171+
Body: billingEntityBodyRenderer,
126172
}
127173
}
128174

@@ -135,7 +181,7 @@ func ControllerCommand() *cobra.Command {
135181
*invTokenValidFor,
136182
*redeemedInvitationTTL,
137183
*invEmailBaseRetryDelay,
138-
mailSender,
184+
invMailSender,
139185
ctrl.Options{
140186
Scheme: scheme,
141187
MetricsBindAddress: *metricsAddr,
@@ -150,11 +196,23 @@ func ControllerCommand() *cobra.Command {
150196
os.Exit(1)
151197
}
152198

199+
cron, err := setupCron(
200+
ctx,
201+
*billingEntityCronInterval,
202+
mgr,
203+
beMailSender,
204+
*billingEntityEmailRecipient,
205+
)
206+
207+
cron.Start()
208+
153209
setupLog.Info("starting manager")
154210
if err := mgr.Start(ctx); err != nil {
155211
setupLog.Error(err, "problem running manager")
156212
os.Exit(1)
157213
}
214+
setupLog.Info("Stopping...")
215+
<-cron.Stop().Done()
158216
}
159217

160218
return cmd
@@ -283,3 +341,38 @@ func setupManager(
283341
}
284342
return mgr, err
285343
}
344+
345+
func setupCron(
346+
ctx context.Context,
347+
crontab string,
348+
mgr ctrl.Manager,
349+
beMailSender mailsenders.MailSender,
350+
beMailRecipient string,
351+
) (*cron.Cron, error) {
352+
353+
bemail := controllers.NewBillingEntityEmailCronJob(
354+
mgr.GetClient(),
355+
mgr.GetEventRecorderFor("invitation-email-controller"),
356+
mgr.GetScheme(),
357+
beMailSender,
358+
beMailRecipient,
359+
)
360+
361+
metrics.Registry.MustRegister(bemail.GetMetrics())
362+
syncLog := ctrl.Log.WithName("cron")
363+
364+
c := cron.New()
365+
_, err := c.AddFunc(crontab, func() {
366+
err := bemail.Run(ctx)
367+
368+
if err == nil {
369+
return
370+
}
371+
syncLog.Error(err, "Error during periodic job")
372+
373+
})
374+
if err != nil {
375+
return nil, err
376+
}
377+
return c, nil
378+
}

0 commit comments

Comments
 (0)