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

SickHub / mailu-operator / 17348818988

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

Pull #135

github

web-flow
Merge 4dfaeb767 into b39033216
Pull Request #135: fix: stop using ctrl.Result.Requeue

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.27
/internal/controller/domain_controller.go
1
package controller
2

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

14
        "k8s.io/apimachinery/pkg/api/meta"
15
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16
        "k8s.io/apimachinery/pkg/runtime"
17
        ctrl "sigs.k8s.io/controller-runtime"
18
        "sigs.k8s.io/controller-runtime/pkg/client"
19
        "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
20
        "sigs.k8s.io/controller-runtime/pkg/log"
21
        "sigs.k8s.io/controller-runtime/pkg/reconcile"
22

23
        operatorv1alpha1 "github.com/sickhub/mailu-operator/api/v1alpha1"
24
        "github.com/sickhub/mailu-operator/pkg/mailu"
25
)
26

27
const (
28
        DomainConditionTypeReady = "DomainReady"
29
)
30

31
// DomainReconciler reconciles a Domain object
32
type DomainReconciler struct {
33
        client.Client
34
        Scheme    *runtime.Scheme
35
        ApiURL    string
36
        ApiToken  string
37
        ApiClient *mailu.Client
38
}
39

40
//+kubebuilder:rbac:groups=operator.mailu.io,resources=domains,verbs=get;list;watch;create;update;patch;delete
41
//+kubebuilder:rbac:groups=operator.mailu.io,resources=domains/status,verbs=get;update;patch
42
//+kubebuilder:rbac:groups=operator.mailu.io,resources=domains/finalizers,verbs=update
43

44
// Reconcile is part of the main kubernetes reconciliation loop which aims to
45
// move the current state of the cluster closer to the desired state.
46
//
47
// For more details, check Reconcile and its Result here:
48
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.17.3/pkg/reconcile
49
func (r *DomainReconciler) Reconcile(ctx context.Context, domain *operatorv1alpha1.Domain) (ctrl.Result, error) {
1✔
50
        logr := log.FromContext(ctx)
1✔
51

1✔
52
        domainOriginal := domain.DeepCopy()
1✔
53

1✔
54
        // apply patches at the end, before returning
1✔
55
        defer func() {
2✔
56
                if err := r.Patch(ctx, domain.DeepCopy(), client.MergeFrom(domainOriginal)); err != nil {
1✔
57
                        logr.Error(err, "failed to patch resource")
×
58
                }
×
59
                if err := r.Status().Patch(ctx, domain.DeepCopy(), client.MergeFrom(domainOriginal)); err != nil {
2✔
60
                        logr.Error(err, "failed to patch resource status")
1✔
61
                }
1✔
62
        }()
63

64
        if domain.DeletionTimestamp == nil && !controllerutil.ContainsFinalizer(domain, FinalizerName) {
2✔
65
                controllerutil.AddFinalizer(domain, FinalizerName)
1✔
66
        }
1✔
67

68
        result, err := r.reconcile(ctx, domain)
1✔
69
        if err != nil {
1✔
70
                return result, err
×
71
        }
×
72

73
        if domainOriginal.DeletionTimestamp != nil && result.RequeueAfter == 0 {
2✔
74
                controllerutil.RemoveFinalizer(domain, FinalizerName)
1✔
75
        }
1✔
76

77
        return result, nil
1✔
78
}
79

80
func (r *DomainReconciler) reconcile(ctx context.Context, domain *operatorv1alpha1.Domain) (ctrl.Result, error) {
1✔
81
        logr := log.FromContext(ctx)
1✔
82

1✔
83
        if r.ApiClient == nil {
2✔
84
                api, err := mailu.NewClient(r.ApiURL, mailu.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
2✔
85
                        req.Header.Add("Authorization", "Bearer "+r.ApiToken)
1✔
86
                        return nil
1✔
87
                }))
1✔
88
                if err != nil {
1✔
89
                        return ctrl.Result{}, err
×
90
                }
×
91
                r.ApiClient = api
1✔
92
        }
93

94
        foundDomain, retry, err := r.getDomain(ctx, domain)
1✔
95
        if err != nil {
2✔
96
                if retry {
2✔
97
                        logr.Info(fmt.Errorf("failed to get domain, requeueing: %w", err).Error())
1✔
98
                        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
1✔
99
                }
1✔
100
                // we explicitly set the error in the status only on a permanent (non-retryable) error
101
                meta.SetStatusCondition(&domain.Status.Conditions, getDomainReadyCondition(metav1.ConditionFalse, "Error", err.Error()))
1✔
102
                logr.Error(err, "failed to get domain")
1✔
103
                return ctrl.Result{}, nil
1✔
104
        }
105

106
        if domain.DeletionTimestamp != nil {
2✔
107
                if foundDomain == nil {
2✔
108
                        // no need to delete it, if it does not exist
1✔
109
                        return ctrl.Result{}, nil
1✔
110
                }
1✔
111
                return r.delete(ctx, domain)
1✔
112
        }
113

114
        if foundDomain == nil {
2✔
115
                return r.create(ctx, domain)
1✔
116
        }
1✔
117

118
        return r.update(ctx, domain, foundDomain)
1✔
119
}
120

121
func (r *DomainReconciler) create(ctx context.Context, domain *operatorv1alpha1.Domain) (ctrl.Result, error) {
1✔
122
        logr := log.FromContext(ctx)
1✔
123

1✔
124
        retry, err := r.createDomain(ctx, domain)
1✔
125
        if err != nil {
2✔
126
                meta.SetStatusCondition(&domain.Status.Conditions, getDomainReadyCondition(metav1.ConditionFalse, "Error", err.Error()))
1✔
127
                if retry {
2✔
128
                        logr.Info(fmt.Errorf("failed to create domain, requeueing: %w", err).Error())
1✔
129
                        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
1✔
130
                }
1✔
131
                logr.Error(err, "failed to create domain")
×
132
                return ctrl.Result{}, err
×
133
        }
134

135
        if retry {
2✔
136
                return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
1✔
137
        }
1✔
138

139
        meta.SetStatusCondition(&domain.Status.Conditions, getDomainReadyCondition(metav1.ConditionTrue, "Created", "Domain created in MailU"))
1✔
140
        logr.Info("created domain")
1✔
141

1✔
142
        return ctrl.Result{}, nil
1✔
143
}
144

145
func (r *DomainReconciler) update(ctx context.Context, domain *operatorv1alpha1.Domain, apiDomain *mailu.Domain) (ctrl.Result, error) {
1✔
146
        logr := log.FromContext(ctx)
1✔
147

1✔
148
        newDomain := mailu.Domain{
1✔
149
                Name:          domain.Spec.Name,
1✔
150
                Alternatives:  &domain.Spec.Alternatives,
1✔
151
                Comment:       &domain.Spec.Comment,
1✔
152
                MaxAliases:    &domain.Spec.MaxAliases,
1✔
153
                MaxQuotaBytes: &domain.Spec.MaxQuotaBytes,
1✔
154
                MaxUsers:      &domain.Spec.MaxUsers,
1✔
155
                SignupEnabled: &domain.Spec.SignupEnabled,
1✔
156
        }
1✔
157

1✔
158
        jsonNew, _ := json.Marshal(newDomain) //nolint:errcheck
1✔
159
        jsonOld, _ := json.Marshal(apiDomain) //nolint:errcheck
1✔
160

1✔
161
        if reflect.DeepEqual(jsonNew, jsonOld) {
2✔
162
                meta.SetStatusCondition(&domain.Status.Conditions, getDomainReadyCondition(metav1.ConditionTrue, "Updated", "Domain updated in MailU"))
1✔
163
                logr.Info("domain is up to date, no change needed")
1✔
164
                return ctrl.Result{}, nil
1✔
165
        }
1✔
166

167
        retry, err := r.updateDomain(ctx, newDomain)
1✔
168
        if err != nil {
2✔
169
                meta.SetStatusCondition(&domain.Status.Conditions, getDomainReadyCondition(metav1.ConditionFalse, "Error", err.Error()))
1✔
170
                if retry {
2✔
171
                        logr.Info(fmt.Errorf("failed to update domain, requeueing: %w", err).Error())
1✔
172
                        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
1✔
173
                }
1✔
174
                logr.Error(err, "failed to update domain")
×
175
                return ctrl.Result{}, err
×
176
        }
177

178
        if retry {
1✔
NEW
179
                return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
×
UNCOV
180
        }
×
181

182
        meta.SetStatusCondition(&domain.Status.Conditions, getDomainReadyCondition(metav1.ConditionTrue, "Updated", "Domain updated in MailU"))
1✔
183
        logr.Info("updated domain")
1✔
184

1✔
185
        return ctrl.Result{}, nil
1✔
186
}
187

188
func (r *DomainReconciler) delete(ctx context.Context, domain *operatorv1alpha1.Domain) (ctrl.Result, error) {
1✔
189
        logr := log.FromContext(ctx)
1✔
190

1✔
191
        retry, err := r.deleteDomain(ctx, domain)
1✔
192
        if err != nil {
2✔
193
                meta.SetStatusCondition(&domain.Status.Conditions, getDomainReadyCondition(metav1.ConditionFalse, "Error", err.Error()))
1✔
194
                if retry {
2✔
195
                        logr.Info(fmt.Errorf("failed to delete domain, requeueing: %w", err).Error())
1✔
196
                        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
1✔
197
                }
1✔
198
                logr.Error(err, "failed to delete domain")
×
199
                return ctrl.Result{}, err
×
200
        }
201

202
        if retry {
1✔
NEW
203
                return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
×
UNCOV
204
        }
×
205

206
        logr.Info("deleted domain")
1✔
207
        return ctrl.Result{}, nil
1✔
208
}
209

210
func (r *DomainReconciler) getDomain(ctx context.Context, domain *operatorv1alpha1.Domain) (*mailu.Domain, bool, error) {
1✔
211
        found, err := r.ApiClient.FindDomain(ctx, domain.Spec.Name)
1✔
212
        if err != nil {
1✔
213
                return nil, false, err
×
214
        }
×
215
        defer found.Body.Close() //nolint:errcheck
1✔
216

1✔
217
        body, err := io.ReadAll(found.Body)
1✔
218
        if err != nil {
1✔
219
                return nil, true, err
×
220
        }
×
221

222
        switch found.StatusCode {
1✔
223
        case http.StatusOK:
1✔
224
                foundDomain := &mailu.Domain{}
1✔
225
                err = json.Unmarshal(body, &foundDomain)
1✔
226
                if err != nil {
1✔
227
                        return nil, true, err
×
228
                }
×
229

230
                return foundDomain, false, nil
1✔
231
        case http.StatusNotFound:
1✔
232
                return nil, false, nil
1✔
233
        case http.StatusBadRequest:
1✔
234
                return nil, false, errors.New("bad request")
1✔
235
        case http.StatusBadGateway:
×
236
                fallthrough
×
237
        case http.StatusGatewayTimeout:
×
238
                return nil, true, errors.New("gateway timeout")
×
239
        case http.StatusServiceUnavailable:
1✔
240
                return nil, true, errors.New("service unavailable")
1✔
241
        }
242
        return nil, false, errors.New("unknown status: " + strconv.Itoa(found.StatusCode))
×
243
}
244

245
func (r *DomainReconciler) createDomain(ctx context.Context, domain *operatorv1alpha1.Domain) (bool, error) {
1✔
246
        res, err := r.ApiClient.CreateDomain(ctx, mailu.Domain{
1✔
247
                Name:          domain.Spec.Name,
1✔
248
                Comment:       &domain.Spec.Comment,
1✔
249
                MaxUsers:      &domain.Spec.MaxUsers,
1✔
250
                MaxAliases:    &domain.Spec.MaxAliases,
1✔
251
                MaxQuotaBytes: &domain.Spec.MaxQuotaBytes,
1✔
252
                SignupEnabled: &domain.Spec.SignupEnabled,
1✔
253
        })
1✔
254
        if err != nil {
1✔
255
                return false, err
×
256
        }
×
257
        defer res.Body.Close() //nolint:errcheck
1✔
258

1✔
259
        _, err = io.ReadAll(res.Body)
1✔
260
        if err != nil {
1✔
261
                return false, err
×
262
        }
×
263
        switch res.StatusCode {
1✔
264
        case http.StatusCreated:
×
265
                fallthrough
×
266
        case http.StatusOK:
1✔
267
                return false, nil
1✔
268
        case http.StatusConflict:
1✔
269
                // treat conflict as success -> requeue will trigger an update
1✔
270
                return true, nil
1✔
271
        case http.StatusInternalServerError:
×
272
                return false, errors.New("internal server error")
×
273
        case http.StatusBadGateway:
×
274
                fallthrough
×
275
        case http.StatusGatewayTimeout:
×
276
                return true, errors.New("gateway timeout")
×
277
        case http.StatusServiceUnavailable:
1✔
278
                return true, errors.New("service unavailable")
1✔
279
        }
280

281
        return false, errors.New("unknown status: " + strconv.Itoa(res.StatusCode))
×
282
}
283

284
func (r *DomainReconciler) updateDomain(ctx context.Context, newDomain mailu.Domain) (bool, error) {
1✔
285
        res, err := r.ApiClient.UpdateDomain(ctx, newDomain.Name, newDomain)
1✔
286
        if err != nil {
1✔
287
                return false, err
×
288
        }
×
289
        defer res.Body.Close() //nolint:errcheck
1✔
290

1✔
291
        _, err = io.ReadAll(res.Body)
1✔
292
        if err != nil {
1✔
293
                return false, err
×
294
        }
×
295

296
        switch res.StatusCode {
1✔
297
        case http.StatusNoContent:
×
298
                fallthrough
×
299
        case http.StatusOK:
1✔
300
                return false, nil
1✔
301
        case http.StatusInternalServerError:
×
302
                return false, errors.New("internal server error")
×
303
        case http.StatusBadGateway:
×
304
                fallthrough
×
305
        case http.StatusGatewayTimeout:
×
306
                return true, errors.New("gateway timeout")
×
307
        case http.StatusServiceUnavailable:
1✔
308
                return true, errors.New("service unavailable")
1✔
309
        }
310

311
        return false, errors.New("unknown status: " + strconv.Itoa(res.StatusCode))
×
312
}
313

314
func (r *DomainReconciler) deleteDomain(ctx context.Context, domain *operatorv1alpha1.Domain) (bool, error) {
1✔
315
        res, err := r.ApiClient.DeleteDomain(ctx, domain.Spec.Name)
1✔
316
        if err != nil {
1✔
317
                return false, err
×
318
        }
×
319
        defer res.Body.Close() //nolint:errcheck
1✔
320

1✔
321
        _, err = io.ReadAll(res.Body)
1✔
322
        if err != nil {
1✔
323
                return false, err
×
324
        }
×
325

326
        switch res.StatusCode {
1✔
327
        case http.StatusNotFound:
×
328
                fallthrough
×
329
        case http.StatusOK:
1✔
330
                return false, nil
1✔
331
        case http.StatusInternalServerError:
×
332
                return false, errors.New("internal server error")
×
333
        case http.StatusBadGateway:
×
334
                fallthrough
×
335
        case http.StatusGatewayTimeout:
×
336
                return true, errors.New("gateway timeout")
×
337
        case http.StatusServiceUnavailable:
1✔
338
                return true, errors.New("service unavailable")
1✔
339
        }
340

341
        return false, errors.New("unknown status: " + strconv.Itoa(res.StatusCode))
×
342
}
343

344
func getDomainReadyCondition(status metav1.ConditionStatus, reason, message string) metav1.Condition {
1✔
345
        return metav1.Condition{
1✔
346
                Type:    DomainConditionTypeReady,
1✔
347
                Status:  status,
1✔
348
                Reason:  reason,
1✔
349
                Message: message,
1✔
350
        }
1✔
351
}
1✔
352

353
// SetupWithManager sets up the controller with the Manager.
354
func (r *DomainReconciler) SetupWithManager(mgr ctrl.Manager) error {
×
355
        return ctrl.NewControllerManagedBy(mgr).
×
356
                For(&operatorv1alpha1.Domain{}).
×
357
                Complete(reconcile.AsReconciler(r.Client, r))
×
358
}
×
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