Skip to content

Commit 17dccc4

Browse files
Merge pull request #160 from appuio/feat/cleanup-broken-records
Add a command to clean up stale inflight records
2 parents 37403e6 + 99861ea commit 17dccc4

File tree

6 files changed

+160
-11
lines changed

6 files changed

+160
-11
lines changed

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ run-controller: build ## Starts control api controller against the current Kuber
7474
$(localenv_make) webhook-certs/tls.key
7575
$(BIN_FILENAME) controller --username-prefix "appuio#" --webhook-cert-dir=./local-env/webhook-certs --webhook-port=9444 --zap-log-level debug --billingentity-email-cron-interval "@every 1m"
7676

77+
.PHONY: run-cleanup
78+
run-cleanup: build ## Starts cleanup command
79+
$(BIN_FILENAME) cleanup --billing-entity-odoo8-url $(BE_ODOO_URL)
80+
7781
.PHONY: local-env
7882
local-env-setup: ## Setup local kind-based dev environment
7983
$(localenv_make) setup

apiserver/billing/odoostorage/odoo/odoo8/client/model/partner.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,7 @@ func (o Odoo) CreatePartner(ctx context.Context, p Partner) (id int, err error)
142142
func (o Odoo) UpdateRawPartner(ctx context.Context, ids []int, raw any) error {
143143
return o.querier.UpdateGenericModel(ctx, PartnerModel, ids, raw)
144144
}
145+
146+
func (o Odoo) DeletePartner(ctx context.Context, ids []int) error {
147+
return o.querier.DeleteGenericModel(ctx, PartnerModel, ids)
148+
}

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

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"strconv"
99
"strings"
10+
"time"
1011

1112
"github.com/google/uuid"
1213
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -31,6 +32,7 @@ var metaUIDNamespace = uuid.MustParse("51887759-C769-4829-9910-BB9D5F92767D")
3132
var roleAccountFilter = []any{"category_id", "in", []int{roleAccountCategory}}
3233
var activeFilter = []any{"active", "in", []bool{true}}
3334
var notInflightFilter = []any{"x_control_api_inflight", "in", []any{false}}
35+
var mustInflightFilter = []any{"x_control_api_inflight", "not in", []any{false}}
3436

3537
var (
3638
// There's a ton of fields we don't want to override in Odoo.
@@ -63,22 +65,36 @@ type Config struct {
6365
PaymentTermID int
6466
}
6567

66-
func NewOdoo8Storage(odooURL string, debugTransport bool, conf Config) odoo.OdooStorage {
67-
return &oodo8Storage{
68+
var _ odoo.OdooStorage = &Odoo8Storage{}
69+
70+
func NewOdoo8Storage(odooURL string, debugTransport bool, conf Config) *Odoo8Storage {
71+
return &Odoo8Storage{
6872
config: conf,
6973
sessionCreator: func(ctx context.Context) (client.QueryExecutor, error) {
7074
return client.Open(ctx, odooURL, client.ClientOptions{UseDebugLogger: debugTransport})
7175
},
7276
}
7377
}
7478

75-
type oodo8Storage struct {
79+
func NewFailedRecordScrubber(odooURL string, debugTransport bool) *FailedRecordScrubber {
80+
return &FailedRecordScrubber{
81+
sessionCreator: func(ctx context.Context) (client.QueryExecutor, error) {
82+
return client.Open(ctx, odooURL, client.ClientOptions{UseDebugLogger: debugTransport})
83+
},
84+
}
85+
}
86+
87+
type Odoo8Storage struct {
7688
config Config
7789

7890
sessionCreator func(ctx context.Context) (client.QueryExecutor, error)
7991
}
8092

81-
func (s *oodo8Storage) Get(ctx context.Context, name string) (*billingv1.BillingEntity, error) {
93+
type FailedRecordScrubber struct {
94+
sessionCreator func(ctx context.Context) (client.QueryExecutor, error)
95+
}
96+
97+
func (s *Odoo8Storage) Get(ctx context.Context, name string) (*billingv1.BillingEntity, error) {
8298
company, accountingContact, err := s.get(ctx, name)
8399
if err != nil {
84100
return nil, err
@@ -88,7 +104,7 @@ func (s *oodo8Storage) Get(ctx context.Context, name string) (*billingv1.Billing
88104
return &be, nil
89105
}
90106

91-
func (s *oodo8Storage) get(ctx context.Context, name string) (company model.Partner, accountingContact model.Partner, err error) {
107+
func (s *Odoo8Storage) get(ctx context.Context, name string) (company model.Partner, accountingContact model.Partner, err error) {
92108
id, err := k8sIDToOdooID(name)
93109
if err != nil {
94110
return model.Partner{}, model.Partner{}, err
@@ -117,7 +133,7 @@ func (s *oodo8Storage) get(ctx context.Context, name string) (company model.Part
117133
return company, accountingContact, nil
118134
}
119135

120-
func (s *oodo8Storage) List(ctx context.Context) ([]billingv1.BillingEntity, error) {
136+
func (s *Odoo8Storage) List(ctx context.Context) ([]billingv1.BillingEntity, error) {
121137
l := klog.FromContext(ctx)
122138

123139
session, err := s.sessionCreator(ctx)
@@ -173,7 +189,7 @@ func (s *oodo8Storage) List(ctx context.Context) ([]billingv1.BillingEntity, err
173189
return bes, nil
174190
}
175191

176-
func (s *oodo8Storage) Create(ctx context.Context, be *billingv1.BillingEntity) error {
192+
func (s *Odoo8Storage) Create(ctx context.Context, be *billingv1.BillingEntity) error {
177193
l := klog.FromContext(ctx)
178194

179195
if be == nil {
@@ -225,7 +241,7 @@ func (s *oodo8Storage) Create(ctx context.Context, be *billingv1.BillingEntity)
225241
return nil
226242
}
227243

228-
func (s *oodo8Storage) Update(ctx context.Context, be *billingv1.BillingEntity) error {
244+
func (s *Odoo8Storage) Update(ctx context.Context, be *billingv1.BillingEntity) error {
229245
l := klog.FromContext(ctx)
230246

231247
if be == nil {
@@ -274,6 +290,42 @@ func (s *oodo8Storage) Update(ctx context.Context, be *billingv1.BillingEntity)
274290
return nil
275291
}
276292

293+
// CleanupIncompleteRecords looks for partner records in Odoo that still have the "inflight" flag set despite being older than `minAge`. Those records are then deleted.
294+
// Such records might come into existence due to a partially failed creation request.
295+
func (s *FailedRecordScrubber) CleanupIncompleteRecords(ctx context.Context, minAge time.Duration) error {
296+
l := klog.FromContext(ctx)
297+
l.Info("Looking for stale inflight partner records...")
298+
299+
session, err := s.sessionCreator(ctx)
300+
if err != nil {
301+
return err
302+
}
303+
o := model.NewOdoo(session)
304+
305+
inflightRecords, err := o.SearchPartners(ctx, []client.Filter{
306+
mustInflightFilter,
307+
})
308+
if err != nil {
309+
return err
310+
}
311+
312+
ids := []int{}
313+
314+
for _, record := range inflightRecords {
315+
createdTime := record.CreationTimestamp.ToTime()
316+
317+
if createdTime.Before(time.Now().Add(-1 * minAge)) {
318+
ids = append(ids, record.ID)
319+
l.Info("Preparing to delete inflight partner record", "name", record.Name, "id", record.ID)
320+
}
321+
}
322+
323+
if len(ids) != 0 {
324+
return o.DeletePartner(ctx, ids)
325+
}
326+
return nil
327+
}
328+
277329
func k8sIDToOdooID(id string) (int, error) {
278330
if !strings.HasPrefix(id, "be-") {
279331
return 0, fmt.Errorf("invalid ID, missing prefix: %s", id)

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

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,11 +390,11 @@ func Test_CreateUpdate_UnknownCountry(t *testing.T) {
390390
require.ErrorContains(t, subject.Update(context.Background(), s), "unknown country")
391391
}
392392

393-
func createStorage(t *testing.T) (*gomock.Controller, *clientmock.MockQueryExecutor, *oodo8Storage) {
393+
func createStorage(t *testing.T) (*gomock.Controller, *clientmock.MockQueryExecutor, *Odoo8Storage) {
394394
ctrl := gomock.NewController(t)
395395
mock := clientmock.NewMockQueryExecutor(ctrl)
396396

397-
return ctrl, mock, &oodo8Storage{
397+
return ctrl, mock, &Odoo8Storage{
398398
config: Config{
399399
CountryIDs: map[string]int{
400400
"": 0,
@@ -410,3 +410,51 @@ func createStorage(t *testing.T) (*gomock.Controller, *clientmock.MockQueryExecu
410410
},
411411
}
412412
}
413+
414+
func createFailedRecordScrubber(t *testing.T) (*gomock.Controller, *clientmock.MockQueryExecutor, *FailedRecordScrubber) {
415+
ctrl := gomock.NewController(t)
416+
mock := clientmock.NewMockQueryExecutor(ctrl)
417+
418+
return ctrl, mock, &FailedRecordScrubber{
419+
sessionCreator: func(ctx context.Context) (client.QueryExecutor, error) {
420+
return mock, nil
421+
},
422+
}
423+
}
424+
425+
func TestCleanup(t *testing.T) {
426+
ctrl, mock, subject := createFailedRecordScrubber(t)
427+
defer ctrl.Finish()
428+
429+
tn := time.Now()
430+
to := tn.Add(time.Hour * -1)
431+
432+
gomock.InOrder(
433+
// Fetch stale records
434+
mock.EXPECT().SearchGenericModel(gomock.Any(), gomock.Any(), gomock.Any()).SetArg(2, model.PartnerList{
435+
Items: []model.Partner{
436+
{
437+
ID: 702,
438+
Name: "Accounting",
439+
CreationTimestamp: client.Date(tn),
440+
Parent: model.OdooCompositeID{ID: 700, Valid: true},
441+
EmailRaw: model.NewNullable("[email protected], [email protected]"),
442+
Inflight: model.NewNullable("fooo"),
443+
},
444+
{
445+
ID: 703,
446+
Name: "Accounting",
447+
CreationTimestamp: client.Date(to),
448+
Parent: model.OdooCompositeID{ID: 700, Valid: true},
449+
EmailRaw: model.NewNullable("[email protected], [email protected]"),
450+
Inflight: model.NewNullable("fooo"),
451+
},
452+
},
453+
}),
454+
mock.EXPECT().DeleteGenericModel(gomock.Any(), gomock.Any(), gomock.Eq([]int{703})).Return(nil),
455+
)
456+
457+
err := subject.CleanupIncompleteRecords(context.Background(), time.Minute)
458+
require.NoError(t, err)
459+
460+
}

cleanup.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"time"
6+
7+
"github.com/spf13/cobra"
8+
"k8s.io/klog/v2"
9+
ctrl "sigs.k8s.io/controller-runtime"
10+
11+
"github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo8"
12+
)
13+
14+
// APICommand creates a new command allowing to start the API server
15+
func CleanupCommand() *cobra.Command {
16+
cmd := &cobra.Command{
17+
Use: "cleanup",
18+
}
19+
20+
odooUrl := cmd.Flags().String("billing-entity-odoo8-url", "http://localhost:8069", "URL of the Odoo instance to use for billing entities")
21+
debugTransport := cmd.Flags().Bool("billing-entity-odoo8-debug-transport", false, "Enable debug logging for the Odoo transport")
22+
minAge := cmd.Flags().Duration("billing-entity-odoo8-cleanup-after", time.Hour, "Clean up only records older than this")
23+
24+
cmd.Run = func(cmd *cobra.Command, args []string) {
25+
ctx := ctrl.SetupSignalHandler()
26+
l := klog.FromContext(ctx)
27+
scrubber := odoo8.NewFailedRecordScrubber(
28+
*odooUrl,
29+
*debugTransport,
30+
)
31+
32+
err := scrubber.CleanupIncompleteRecords(ctx, *minAge)
33+
if err != nil {
34+
l.Error(err, "Unable to clean up incomplete records")
35+
os.Exit(1)
36+
}
37+
l.Info("Cleanup complete!")
38+
}
39+
40+
return cmd
41+
}

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ var (
2020
)
2121

2222
func main() {
23-
rootCommand.AddCommand(ControllerCommand(), APICommand())
23+
rootCommand.AddCommand(ControllerCommand(), APICommand(), CleanupCommand())
2424

2525
if err := rootCommand.Execute(); err != nil {
2626
fmt.Fprintln(os.Stderr, err)

0 commit comments

Comments
 (0)