• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SickHub / mailu-operator / 17348864456

30 Aug 2025 09:38PM UTC coverage: 38.019% (-0.09%) from 38.11%
17348864456

push

github

web-flow
fix: stop using ctrl.Result.Requeue (#135)

59 of 65 new or added lines in 3 files covered. (90.77%)

6 existing lines in 3 files now uncovered.

614 of 1615 relevant lines covered (38.02%)

0.42 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

72.73
/internal/controller/user_controller.go
1
package controller
2

3
import (
4
        "context"
5
        "encoding/base64"
6
        "encoding/json"
7
        "errors"
8
        "fmt"
9
        "io"
10
        "net/http"
11
        "reflect"
12
        "strconv"
13
        "time"
14

15
        openapitypes "github.com/oapi-codegen/runtime/types"
16
        "github.com/sethvargo/go-password/password"
17
        corev1 "k8s.io/api/core/v1"
18
        "k8s.io/apimachinery/pkg/api/meta"
19
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
20
        "k8s.io/apimachinery/pkg/runtime"
21
        "k8s.io/apimachinery/pkg/types"
22
        ctrl "sigs.k8s.io/controller-runtime"
23
        "sigs.k8s.io/controller-runtime/pkg/client"
24
        "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
25
        "sigs.k8s.io/controller-runtime/pkg/log"
26
        "sigs.k8s.io/controller-runtime/pkg/reconcile"
27

28
        operatorv1alpha1 "github.com/sickhub/mailu-operator/api/v1alpha1"
29
        "github.com/sickhub/mailu-operator/pkg/mailu"
30
)
31

32
const (
33
        UserConditionTypeReady = "UserReady"
34
)
35

36
// UserReconciler reconciles a User object
37
type UserReconciler struct {
38
        client.Client
39
        Scheme    *runtime.Scheme
40
        ApiURL    string
41
        ApiToken  string
42
        ApiClient *mailu.Client
43
}
44

45
//+kubebuilder:rbac:groups=operator.mailu.io,resources=users,verbs=get;list;watch;create;update;patch;delete
46
//+kubebuilder:rbac:groups=operator.mailu.io,resources=users/status,verbs=get;update;patch
47
//+kubebuilder:rbac:groups=operator.mailu.io,resources=users/finalizers,verbs=update
48
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list
49

50
// Reconcile is part of the main kubernetes reconciliation loop which aims to
51
// move the current state of the cluster closer to the desired state.
52
//
53
// For more details, check Reconcile and its Result here:
54
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.17.3/pkg/reconcile
55
func (r *UserReconciler) Reconcile(ctx context.Context, user *operatorv1alpha1.User) (ctrl.Result, error) {
1✔
56
        logr := log.FromContext(ctx)
1✔
57

1✔
58
        userOriginal := user.DeepCopy()
1✔
59

1✔
60
        // apply patches at the end, before returning
1✔
61
        defer func() {
2✔
62
                if err := r.Patch(ctx, user.DeepCopy(), client.MergeFrom(userOriginal)); err != nil {
1✔
63
                        logr.Error(err, "failed to patch resource")
×
64
                }
×
65
                if err := r.Status().Patch(ctx, user.DeepCopy(), client.MergeFrom(userOriginal)); err != nil {
2✔
66
                        logr.Error(err, "failed to patch resource status")
1✔
67
                }
1✔
68
        }()
69

70
        if user.DeletionTimestamp == nil && !controllerutil.ContainsFinalizer(user, FinalizerName) {
2✔
71
                controllerutil.AddFinalizer(user, FinalizerName)
1✔
72
        }
1✔
73

74
        result, err := r.reconcile(ctx, user)
1✔
75
        if err != nil {
1✔
76
                return result, err
×
77
        }
×
78

79
        if userOriginal.DeletionTimestamp != nil && result.RequeueAfter == 0 {
2✔
80
                controllerutil.RemoveFinalizer(user, FinalizerName)
1✔
81
        }
1✔
82

83
        return result, nil
1✔
84
}
85

86
func (r *UserReconciler) reconcile(ctx context.Context, user *operatorv1alpha1.User) (ctrl.Result, error) {
1✔
87
        logr := log.FromContext(ctx)
1✔
88

1✔
89
        if r.ApiClient == nil {
2✔
90
                api, err := mailu.NewClient(r.ApiURL, mailu.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
2✔
91
                        req.Header.Add("Authorization", "Bearer "+r.ApiToken)
1✔
92
                        return nil
1✔
93
                }))
1✔
94
                if err != nil {
1✔
95
                        return ctrl.Result{}, err
×
96
                }
×
97
                r.ApiClient = api
1✔
98
        }
99

100
        foundUser, retry, err := r.getUser(ctx, user)
1✔
101
        if err != nil {
2✔
102
                if retry {
2✔
103
                        logr.Info(fmt.Errorf("failed to get user, requeueing: %w", err).Error())
1✔
104
                        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
1✔
105
                }
1✔
106
                // we explicitly set the error in the status only on a permanent (non-retryable) error
107
                meta.SetStatusCondition(&user.Status.Conditions, getUserReadyCondition(metav1.ConditionFalse, "Error", err.Error()))
1✔
108
                logr.Error(err, "failed to get user")
1✔
109
                return ctrl.Result{}, nil
1✔
110
        }
111

112
        if user.DeletionTimestamp != nil {
2✔
113
                if foundUser == nil {
2✔
114
                        // no need to delete it, if it does not exist
1✔
115
                        return ctrl.Result{}, nil
1✔
116
                }
1✔
117
                return r.delete(ctx, user)
1✔
118
        }
119

120
        if foundUser == nil {
2✔
121
                return r.create(ctx, user)
1✔
122
        }
1✔
123

124
        return r.update(ctx, user, foundUser)
1✔
125
}
126

127
func (r *UserReconciler) create(ctx context.Context, user *operatorv1alpha1.User) (ctrl.Result, error) {
1✔
128
        logr := log.FromContext(ctx)
1✔
129

1✔
130
        retry, err := r.createUser(ctx, user)
1✔
131
        if err != nil {
2✔
132
                meta.SetStatusCondition(&user.Status.Conditions, getUserReadyCondition(metav1.ConditionFalse, "Error", err.Error()))
1✔
133
                if retry {
2✔
134
                        logr.Info(fmt.Errorf("failed to create user, requeueing: %w", err).Error())
1✔
135
                        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
1✔
136
                }
1✔
137
                logr.Error(err, "failed to create user")
×
138
                return ctrl.Result{}, err
×
139
        }
140

141
        if retry {
2✔
142
                return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
1✔
143
        }
1✔
144

145
        meta.SetStatusCondition(&user.Status.Conditions, getUserReadyCondition(metav1.ConditionTrue, "Created", "User created in MailU"))
1✔
146
        logr.Info("created user")
1✔
147

1✔
148
        return ctrl.Result{}, nil
1✔
149
}
150

151
func (r *UserReconciler) update(ctx context.Context, user *operatorv1alpha1.User, apiUser *mailu.User) (ctrl.Result, error) {
1✔
152
        logr := log.FromContext(ctx)
1✔
153

1✔
154
        newUser, err := r.userFromSpec(user.Spec)
1✔
155
        if err != nil {
1✔
156
                meta.SetStatusCondition(&user.Status.Conditions, getUserReadyCondition(metav1.ConditionFalse, "Error", err.Error()))
×
157
                logr.Error(err, "failed to get user from spec")
×
158
                return ctrl.Result{}, err
×
159
        }
×
160

161
        // reset some values that should not be updated
162
        newUser.RawPassword = nil
1✔
163
        apiUser.Password = nil
1✔
164
        apiUser.QuotaBytesUsed = nil
1✔
165

1✔
166
        jsonNew, _ := json.Marshal(newUser) //nolint:errcheck
1✔
167
        jsonOld, _ := json.Marshal(apiUser) //nolint:errcheck
1✔
168

1✔
169
        if reflect.DeepEqual(jsonNew, jsonOld) {
2✔
170
                meta.SetStatusCondition(&user.Status.Conditions, getUserReadyCondition(metav1.ConditionTrue, "Updated", "User updated in MailU"))
1✔
171
                logr.Info("user is up to date, no change needed")
1✔
172
                return ctrl.Result{}, nil
1✔
173
        }
1✔
174

175
        retry, err := r.updateUser(ctx, newUser)
1✔
176
        if err != nil {
2✔
177
                meta.SetStatusCondition(&user.Status.Conditions, getUserReadyCondition(metav1.ConditionFalse, "Error", err.Error()))
1✔
178
                if retry {
2✔
179
                        logr.Info(fmt.Errorf("failed to update user, requeueing: %w", err).Error())
1✔
180
                        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
1✔
181
                }
1✔
182
                logr.Error(err, "failed to update user")
×
183
                return ctrl.Result{}, err
×
184
        }
185

186
        if retry {
1✔
NEW
187
                return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
×
UNCOV
188
        }
×
189

190
        meta.SetStatusCondition(&user.Status.Conditions, getUserReadyCondition(metav1.ConditionTrue, "Updated", "User updated in MailU"))
1✔
191
        logr.Info("updated user")
1✔
192

1✔
193
        return ctrl.Result{}, nil
1✔
194
}
195

196
func (r *UserReconciler) delete(ctx context.Context, user *operatorv1alpha1.User) (ctrl.Result, error) {
1✔
197
        logr := log.FromContext(ctx)
1✔
198

1✔
199
        retry, err := r.deleteUser(ctx, user)
1✔
200
        if err != nil {
2✔
201
                meta.SetStatusCondition(&user.Status.Conditions, getUserReadyCondition(metav1.ConditionFalse, "Error", err.Error()))
1✔
202
                if retry {
2✔
203
                        logr.Info(fmt.Errorf("failed to delete user, requeueing: %w", err).Error())
1✔
204
                        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
1✔
205
                }
1✔
206
                logr.Error(err, "failed to delete user")
×
207
                return ctrl.Result{}, err
×
208
        }
209

210
        if retry {
1✔
NEW
211
                return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
×
UNCOV
212
        }
×
213

214
        logr.Info("deleted user")
1✔
215

1✔
216
        return ctrl.Result{}, nil
1✔
217
}
218

219
func (r *UserReconciler) getUser(ctx context.Context, user *operatorv1alpha1.User) (*mailu.User, bool, error) {
1✔
220
        found, err := r.ApiClient.FindUser(ctx, user.Spec.Name+"@"+user.Spec.Domain)
1✔
221
        if err != nil {
1✔
222
                return nil, false, err
×
223
        }
×
224
        defer found.Body.Close() //nolint:errcheck
1✔
225

1✔
226
        body, err := io.ReadAll(found.Body)
1✔
227
        if err != nil {
1✔
228
                return nil, true, err
×
229
        }
×
230

231
        switch found.StatusCode {
1✔
232
        case http.StatusOK:
1✔
233
                foundUser := &mailu.User{}
1✔
234
                err = json.Unmarshal(body, &foundUser)
1✔
235
                if err != nil {
1✔
236
                        return nil, true, err
×
237
                }
×
238

239
                return foundUser, false, nil
1✔
240
        case http.StatusNotFound:
1✔
241
                return nil, false, nil
1✔
242
        case http.StatusBadRequest:
1✔
243
                return nil, false, errors.New("bad request")
1✔
244
        case http.StatusBadGateway:
×
245
                fallthrough
×
246
        case http.StatusGatewayTimeout:
×
247
                return nil, true, errors.New("gateway timeout")
×
248
        case http.StatusServiceUnavailable:
1✔
249
                return nil, true, errors.New("service unavailable")
1✔
250
        }
251
        return nil, false, errors.New("unknown status: " + strconv.Itoa(found.StatusCode))
×
252
}
253

254
func (r *UserReconciler) createUser(ctx context.Context, user *operatorv1alpha1.User) (bool, error) {
1✔
255
        logr := log.FromContext(ctx, "user", user.Name)
1✔
256
        email := user.Spec.Name + "@" + user.Spec.Domain
1✔
257

1✔
258
        // raw password is required during creation
1✔
259
        if user.Spec.RawPassword == "" {
1✔
260
                var err error
×
261
                user.Spec.RawPassword, err = r.getRawUserPassword(ctx, user)
×
262
                if err != nil {
×
263
                        logr.Error(err, fmt.Sprintf("failed to get password for user %s", email))
×
264
                        // retry, because Secret could appear
×
265
                        return true, err
×
266
                }
×
267
        }
268

269
        newUser, err := r.userFromSpec(user.Spec)
1✔
270
        if err != nil {
1✔
271
                return false, err
×
272
        }
×
273

274
        res, err := r.ApiClient.CreateUser(ctx, newUser)
1✔
275
        if err != nil {
1✔
276
                return false, err
×
277
        }
×
278
        defer res.Body.Close() //nolint:errcheck
1✔
279

1✔
280
        _, err = io.ReadAll(res.Body)
1✔
281
        if err != nil {
1✔
282
                return false, err
×
283
        }
×
284
        switch res.StatusCode {
1✔
285
        case http.StatusCreated:
×
286
                fallthrough
×
287
        case http.StatusOK:
1✔
288
                return false, nil
1✔
289
        case http.StatusConflict:
1✔
290
                // treat conflict as success -> requeue will trigger an update
1✔
291
                return true, nil
1✔
292
        case http.StatusInternalServerError:
×
293
                return false, errors.New("internal server error")
×
294
        case http.StatusBadGateway:
×
295
                fallthrough
×
296
        case http.StatusGatewayTimeout:
×
297
                return true, errors.New("gateway timeout")
×
298
        case http.StatusServiceUnavailable:
1✔
299
                return true, errors.New("service unavailable")
1✔
300
        }
301

302
        return false, errors.New("unknown status: " + strconv.Itoa(res.StatusCode))
×
303
}
304

305
func (r *UserReconciler) updateUser(ctx context.Context, newUser mailu.User) (bool, error) {
1✔
306
        res, err := r.ApiClient.UpdateUser(ctx, newUser.Email, newUser)
1✔
307
        if err != nil {
1✔
308
                return false, err
×
309
        }
×
310
        defer res.Body.Close() //nolint:errcheck
1✔
311

1✔
312
        _, err = io.ReadAll(res.Body)
1✔
313
        if err != nil {
1✔
314
                return false, err
×
315
        }
×
316

317
        switch res.StatusCode {
1✔
318
        case http.StatusNoContent:
×
319
                fallthrough
×
320
        case http.StatusOK:
1✔
321
                return false, nil
1✔
322
        case http.StatusInternalServerError:
×
323
                return false, errors.New("internal server error")
×
324
        case http.StatusBadGateway:
×
325
                fallthrough
×
326
        case http.StatusGatewayTimeout:
×
327
                return true, errors.New("gateway timeout")
×
328
        case http.StatusServiceUnavailable:
1✔
329
                return true, errors.New("service unavailable")
1✔
330
        }
331

332
        return false, errors.New("unknown status: " + strconv.Itoa(res.StatusCode))
×
333
}
334

335
func (r *UserReconciler) deleteUser(ctx context.Context, user *operatorv1alpha1.User) (bool, error) {
1✔
336
        res, err := r.ApiClient.DeleteUser(ctx, user.Spec.Name+"@"+user.Spec.Domain)
1✔
337
        if err != nil {
1✔
338
                return false, err
×
339
        }
×
340
        defer res.Body.Close() //nolint:errcheck
1✔
341

1✔
342
        _, err = io.ReadAll(res.Body)
1✔
343
        if err != nil {
1✔
344
                return false, err
×
345
        }
×
346

347
        switch res.StatusCode {
1✔
348
        case http.StatusNotFound:
×
349
                fallthrough
×
350
        case http.StatusOK:
1✔
351
                return false, nil
1✔
352
        case http.StatusInternalServerError:
×
353
                return false, errors.New("internal server error")
×
354
        case http.StatusBadGateway:
×
355
                fallthrough
×
356
        case http.StatusGatewayTimeout:
×
357
                return true, errors.New("gateway timeout")
×
358
        case http.StatusServiceUnavailable:
1✔
359
                return true, errors.New("service unavailable")
1✔
360
        }
361

362
        return false, errors.New("unknown status: " + strconv.Itoa(res.StatusCode))
×
363
}
364

365
func (r *UserReconciler) userFromSpec(spec operatorv1alpha1.UserSpec) (mailu.User, error) {
1✔
366
        u := mailu.User{
1✔
367
                Email:              spec.Name + "@" + spec.Domain,
1✔
368
                AllowSpoofing:      &spec.AllowSpoofing,
1✔
369
                ChangePwNextLogin:  &spec.ChangePassword,
1✔
370
                Comment:            &spec.Comment,
1✔
371
                DisplayedName:      &spec.DisplayedName,
1✔
372
                EnableImap:         &spec.EnableIMAP,
1✔
373
                EnablePop:          &spec.EnablePOP,
1✔
374
                Enabled:            &spec.Enabled,
1✔
375
                ForwardDestination: &spec.ForwardDestination,
1✔
376
                ForwardEnabled:     &spec.ForwardEnabled,
1✔
377
                ForwardKeep:        &spec.ForwardKeep,
1✔
378
                GlobalAdmin:        &spec.GlobalAdmin,
1✔
379
                QuotaBytes:         &spec.QuotaBytes,
1✔
380
                RawPassword:        &spec.RawPassword,
1✔
381
                ReplyBody:          &spec.ReplyBody,
1✔
382
                ReplyEnabled:       &spec.ReplyEnabled,
1✔
383
                ReplySubject:       &spec.ReplySubject,
1✔
384
                SpamEnabled:        &spec.SpamEnabled,
1✔
385
                SpamMarkAsRead:     &spec.SpamMarkAsRead,
1✔
386
                SpamThreshold:      &spec.SpamThreshold,
1✔
387
        }
1✔
388

1✔
389
        // convert Dates if set
1✔
390
        if spec.ReplyStartDate != "" {
2✔
391
                d := &openapitypes.Date{}
1✔
392
                err := d.UnmarshalText([]byte(spec.ReplyStartDate))
1✔
393
                if err != nil {
1✔
394
                        return mailu.User{}, err
×
395
                }
×
396
                u.ReplyStartDate = d
1✔
397
        }
398
        if spec.ReplyEndDate != "" {
2✔
399
                d := &openapitypes.Date{}
1✔
400
                err := d.UnmarshalText([]byte(spec.ReplyEndDate))
1✔
401
                if err != nil {
1✔
402
                        return mailu.User{}, err
×
403
                }
×
404
                u.ReplyEndDate = d
1✔
405
        }
406

407
        return u, nil
1✔
408
}
409

410
func (r *UserReconciler) getRawUserPassword(ctx context.Context, user *operatorv1alpha1.User) (string, error) {
1✔
411
        var err error
1✔
412
        pass := ""
1✔
413
        email := user.Spec.Name + "@" + user.Spec.Domain
1✔
414
        if user.Spec.PasswordSecret != "" && user.Spec.PasswordKey != "" {
2✔
415
                pass, err = r.getUserPassword(ctx, user.Namespace, user.Spec.PasswordSecret, user.Spec.PasswordKey)
1✔
416
                if err != nil {
2✔
417
                        log.FromContext(ctx).Error(err, fmt.Sprintf("failed to get password from secret %s/%s", user.Namespace, user.Spec.PasswordSecret))
1✔
418
                        return pass, err
1✔
419
                }
1✔
420
                log.FromContext(ctx).Info(fmt.Sprintf("using password from secret for user %s", email))
1✔
421
        } else {
1✔
422
                // initial random password if none given
1✔
423
                pass, err = password.Generate(20, 2, 2, false, false)
1✔
424
                if err != nil {
1✔
425
                        log.FromContext(ctx).Error(err, fmt.Sprintf("failed to generate password for user %s", email))
×
426
                        return pass, err
×
427
                }
×
428
                log.FromContext(ctx).Info(fmt.Sprintf("using generated password for user %s", email))
1✔
429
        }
430
        return pass, nil
1✔
431
}
432

433
func (r *UserReconciler) getUserPassword(ctx context.Context, namespace, secret, key string) (string, error) {
1✔
434
        s := &corev1.Secret{}
1✔
435
        err := r.Get(ctx, types.NamespacedName{Name: secret, Namespace: namespace}, s)
1✔
436
        if err != nil {
2✔
437
                return "", err
1✔
438
        }
1✔
439

440
        if _, ok := s.Data[key]; !ok {
2✔
441
                return "", errors.New("secret does not contain key " + key)
1✔
442
        }
1✔
443

444
        pass := make([]byte, base64.StdEncoding.DecodedLen(len(s.Data[key])))
1✔
445
        decoded, err := base64.StdEncoding.Decode(pass, s.Data[key])
1✔
446
        if err != nil {
1✔
447
                return "", err
×
448
        }
×
449

450
        return string(pass[:decoded]), nil
1✔
451
}
452

453
func getUserReadyCondition(status metav1.ConditionStatus, reason, message string) metav1.Condition {
1✔
454
        return metav1.Condition{
1✔
455
                Type:    UserConditionTypeReady,
1✔
456
                Status:  status,
1✔
457
                Reason:  reason,
1✔
458
                Message: message,
1✔
459
        }
1✔
460
}
1✔
461

462
// SetupWithManager sets up the controller with the Manager.
463
func (r *UserReconciler) SetupWithManager(mgr ctrl.Manager) error {
×
464
        return ctrl.NewControllerManagedBy(mgr).
×
465
                For(&operatorv1alpha1.User{}).
×
466
                Complete(reconcile.AsReconciler(r.Client, r))
×
467
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc