diff --git a/api.go b/api.go index 928bb133..dd57abc4 100644 --- a/api.go +++ b/api.go @@ -19,6 +19,7 @@ import ( "github.com/appuio/control-api/apiserver/authwrapper" billingStore "github.com/appuio/control-api/apiserver/billing" "github.com/appuio/control-api/apiserver/billing/odoostorage" + "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo16" "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo8" "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo8/countries" orgStore "github.com/appuio/control-api/apiserver/organization" @@ -55,16 +56,25 @@ func APICommand() *cobra.Command { cmd.Flags().StringVar(&usernamePrefix, "username-prefix", "", "Prefix prepended to username claims. Usually the same as \"--oidc-username-prefix\" of the Kubernetes API server") cmd.Flags().BoolVar(&allowEmptyBillingEntity, "allow-empty-billing-entity", true, "Allow empty billing entity references") - cmd.Flags().StringVar(&ob.billingEntityStorage, "billing-entity-storage", "fake", "Storage backend for billing entities. Supported values: fake, odoo8") + cmd.Flags().StringVar(&ob.billingEntityStorage, "billing-entity-storage", "fake", "Storage backend for billing entities. Supported values: fake, odoo8, odoo16") + cmd.Flags().BoolVar(&ob.billingEntityFakeMetadataSupport, "billing-entity-fake-metadata-support", false, "Enable metadata support for the fake storage backend") + cmd.Flags().StringVar(&ob.odoo8URL, "billing-entity-odoo8-url", "http://localhost:8069", "URL of the Odoo instance to use for billing entities") cmd.Flags().BoolVar(&ob.odoo8DebugTransport, "billing-entity-odoo8-debug-transport", false, "Enable debug logging for the Odoo transport") cmd.Flags().StringVar(&ob.odoo8CountryListPath, "billing-entity-odoo8-country-list", "countries.yaml", "Path to the country list file in the format of [{name: \"Germany\", code: \"DE\", id: 81},...]") - cmd.Flags().StringVar(&ob.odoo8AccountingContactDisplayName, "billing-entity-odoo8-accounting-contact-display-name", "Accounting", "Display name of the accounting contact") cmd.Flags().StringVar(&ob.odoo8LanguagePreference, "billing-entity-odoo8-language-preference", "en_US", "Language preference of the Odoo record") cmd.Flags().IntVar(&ob.odoo8PaymentTermID, "billing-entity-odoo8-payment-term-id", 2, "Payment term ID of the Odoo record") + cmd.Flags().StringVar(&ob.odoo16URL, "billing-entity-odoo16-url", "http://localhost:8069", "URL of the Odoo instance to use for billing entities") + cmd.Flags().StringVar(&ob.odoo16Db, "billing-entity-odoo16-db", "odooDB", "Database of the Odoo instance to use for billing entities") + cmd.Flags().StringVar(&ob.odoo16Account, "billing-entity-odoo16-account", "Admin", "Odoo Account name to use for billing entities") + cmd.Flags().StringVar(&ob.odoo16Password, "billing-entity-odoo16-password", "superSecret1238", "Odoo Account password to use for billing entities") + cmd.Flags().StringVar(&ob.odoo16CountryListPath, "billing-entity-odoo16-country-list", "countries.yaml", "Path to the country list file in the format of [{name: \"Germany\", code: \"DE\", id: 81},...]") + cmd.Flags().StringVar(&ob.odoo16LanguagePreference, "billing-entity-odoo16-language-preference", "en_US", "Language preference of the Odoo record") + cmd.Flags().IntVar(&ob.odoo16PaymentTermID, "billing-entity-odoo16-payment-term-id", 2, "Payment term ID of the Odoo record") + cmd.Flags().StringVar(&ib.backingNS, "invitation-storage-backing-ns", "default", "Namespace to store invitation secrets in") rf := cmd.Run @@ -90,6 +100,10 @@ type odooStorageBuilder struct { odoo8AccountingContactDisplayName, odoo8LanguagePreference string odoo8PaymentTermID int billingEntityFakeMetadataSupport, odoo8DebugTransport bool + odoo16LanguagePreference string + odoo16URL, odoo16CountryListPath string + odoo16Db, odoo16Account, odoo16Password string + odoo16PaymentTermID int } func (o *odooStorageBuilder) Build(s *runtime.Scheme, g genericregistry.RESTOptionsGetter) (rest.Storage, error) { @@ -107,6 +121,22 @@ func (o *odooStorageBuilder) Build(s *runtime.Scheme, g genericregistry.RESTOpti PaymentTermID: o.odoo8PaymentTermID, CountryIDs: countryIDs, }).(authwrapper.StorageScoper))(s, g) + case "odoo16": + countryIDs, err := countries.LoadCountryIDs(o.odoo16CountryListPath) + if err != nil { + return nil, err + } + return billingStore.New(odoostorage.NewOdoo16Storage( + odoo16.OdooCredentials{ + URL: o.odoo16URL, + Admin: o.odoo16Account, + Password: o.odoo16Password, + Database: o.odoo16Db, + }, odoo16.Config{ + LanguagePreference: o.odoo16LanguagePreference, + PaymentTermID: o.odoo16PaymentTermID, + CountryIDs: countryIDs, + }).(authwrapper.StorageScoper))(s, g) default: return nil, fmt.Errorf("unknown billing entity storage: %s", o.billingEntityStorage) } diff --git a/apiserver/billing/odoostorage/odoo/odoo16/odoo16.go b/apiserver/billing/odoostorage/odoo/odoo16/odoo16.go new file mode 100644 index 00000000..8dafd530 --- /dev/null +++ b/apiserver/billing/odoostorage/odoo/odoo16/odoo16.go @@ -0,0 +1,451 @@ +package odoo16 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/klog/v2" + + billingv1 "github.com/appuio/control-api/apis/billing/v1" + "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo" + + odooclient "github.com/appuio/go-odoo" +) + +const VSHNAccountingContactNameKey = "billing.appuio.io/vshn-accounting-contact-name" + +// Used to identify the accounting contact of a company. +const roleAccountCategory = 70 +const companyCategory = 2 +const invoiceType = "invoice" + +// Used to generate the UUID for the .metadata.uid field. +var metaUIDNamespace = uuid.MustParse("7550b1ae-7a2a-485e-a75d-6f931b2cd73f") + +var roleAccountFilter = odooclient.NewCriterion("category_id", "in", []int{roleAccountCategory}) +var activeFilter = odooclient.NewCriterion("active", "=", true) +var notInflightFilter = odooclient.NewCriterion("vshn_control_api_inflight", "=", false) +var mustInflightFilter = odooclient.NewCriterion("vshn_control_api_inflight", "!=", false) + +var fetchPartnerFieldOpts = odooclient.NewOptions().FetchFields( + "id", + "type", + "name", + "display_name", + "country_id", + "commercial_partner_id", + "contact_address", + + "child_ids", + "user_ids", + + "email", + "phone", + "street", + "street2", + "city", + "zip", + "country_id", + + "parent_id", + "vshn_control_api_meta_status", + "vshn_control_api_inflight", +) + +type OdooCredentials = odooclient.ClientConfig + +type Config struct { + CountryIDs map[string]int + LanguagePreference string + PaymentTermID int +} + +var _ odoo.OdooStorage = &Odoo16Storage{} + +func NewOdoo16Storage(credentials OdooCredentials, conf Config) *Odoo16Storage { + return &Odoo16Storage{ + config: conf, + sessionCreator: func(ctx context.Context) (Odoo16Client, error) { + return odooclient.NewClient(&credentials) + }, + } +} + +func NewFailedRecordScrubber(credentials OdooCredentials) *FailedRecordScrubber { + return &FailedRecordScrubber{ + sessionCreator: func(ctx context.Context) (Odoo16Client, error) { + return odooclient.NewClient(&credentials) + }, + } +} + +type Odoo16Storage struct { + config Config + + sessionCreator func(ctx context.Context) (Odoo16Client, error) +} + +type FailedRecordScrubber struct { + sessionCreator func(ctx context.Context) (Odoo16Client, error) +} + +type Odoo16Client interface { + Read(string, []int64, *odooclient.Options, interface{}) error + Update(string, []int64, interface{}) error + FindResPartners(*odooclient.Criteria, *odooclient.Options) (*odooclient.ResPartners, error) + CreateResPartner(*odooclient.ResPartner) (int64, error) + UpdateResPartner(*odooclient.ResPartner) error + DeleteResPartners([]int64) error +} + +func (s *Odoo16Storage) Get(ctx context.Context, name string) (*billingv1.BillingEntity, error) { + company, accountingContact, err := s.get(ctx, name) + if err != nil { + return nil, err + } + + be := mapPartnersToBillingEntity(ctx, company, accountingContact) + return &be, nil +} + +func (s *Odoo16Storage) get(ctx context.Context, name string) (company odooclient.ResPartner, accountingContact odooclient.ResPartner, err error) { + id, err := k8sIDToOdooID(name) + if err != nil { + return odooclient.ResPartner{}, odooclient.ResPartner{}, err + } + + session, err := s.sessionCreator(ctx) + if err != nil { + return odooclient.ResPartner{}, odooclient.ResPartner{}, err + } + + u := []odooclient.ResPartner{} + err = session.Read(odooclient.ResPartnerModel, []int64{int64(id)}, fetchPartnerFieldOpts, &u) + if err != nil { + return odooclient.ResPartner{}, odooclient.ResPartner{}, fmt.Errorf("fetching accounting contact by ID: %w", err) + } + if len(u) <= 0 { + return odooclient.ResPartner{}, odooclient.ResPartner{}, fmt.Errorf("no results when fetching accounting contact by ID") + } + accountingContact = u[0] + + if accountingContact.ParentId == nil { + return odooclient.ResPartner{}, odooclient.ResPartner{}, fmt.Errorf("accounting contact %d has no parent", id) + } + + err = session.Read(odooclient.ResPartnerModel, []int64{accountingContact.ParentId.ID}, fetchPartnerFieldOpts, &u) + if err != nil { + return odooclient.ResPartner{}, odooclient.ResPartner{}, fmt.Errorf("fetching parent %d of accounting contact %d failed: %w", accountingContact.ParentId.ID, id, err) + } + if len(u) <= 0 { + return odooclient.ResPartner{}, odooclient.ResPartner{}, fmt.Errorf("no results when fetching parent %d of accounting contact %d failed", accountingContact.ParentId.ID, id) + } + company = u[0] + + return company, accountingContact, nil +} + +func (s *Odoo16Storage) List(ctx context.Context) ([]billingv1.BillingEntity, error) { + l := klog.FromContext(ctx) + + session, err := s.sessionCreator(ctx) + if err != nil { + return nil, err + } + + criteria := odooclient.NewCriteria().AddCriterion(roleAccountFilter).AddCriterion(activeFilter).AddCriterion(notInflightFilter) + accPartners, err := session.FindResPartners(criteria, fetchPartnerFieldOpts) + if err != nil { + return nil, err + } + + companyIDs := make([]int, 0, len(*accPartners)) + for _, p := range *accPartners { + if p.ParentId == nil { + l.Info("role account has no parent", "id", p.Id) + continue + } + companyIDs = append(companyIDs, int(p.ParentId.ID)) + } + + criteria = odooclient.NewCriteria().AddCriterion(activeFilter).AddCriterion(odooclient.NewCriterion("id", "in", companyIDs)) + companies, err := session.FindResPartners(criteria, fetchPartnerFieldOpts) + if err != nil { + return nil, err + } + + companySet := make(map[int]odooclient.ResPartner, len(*companies)) + for _, p := range *companies { + companySet[int(p.Id.Get())] = p + } + + bes := make([]billingv1.BillingEntity, 0, len(*accPartners)) + for _, p := range *accPartners { + if p.ParentId == nil { + continue + } + mp, ok := companySet[int(p.ParentId.ID)] + if !ok { + l.Info("could not load parent partner (maybe no longer active?)", "parent_id", p.ParentId.ID, "id", p.Id) + continue + } + bes = append(bes, mapPartnersToBillingEntity(ctx, mp, p)) + } + + return bes, nil +} + +func (s *Odoo16Storage) Create(ctx context.Context, be *billingv1.BillingEntity) error { + l := klog.FromContext(ctx) + + if be == nil { + return errors.New("billing entity is nil") + } + company, accounting, err := mapBillingEntityToPartners(*be, s.config.CountryIDs) + if err != nil { + return fmt.Errorf("failed mapping billing entity to partners: %w", err) + } + + inflight := uuid.New().String() + l = l.WithValues("debug_inflight", inflight) + company.VshnControlApiInflight = odooclient.NewString(inflight) + accounting.VshnControlApiInflight = odooclient.NewString(inflight) + setStaticCompanyFields(s.config, &company) + setStaticAccountingContactFields(s.config, &accounting) + + session, err := s.sessionCreator(ctx) + if err != nil { + return err + } + + l.Info("about to create partner") + companyID, err := session.CreateResPartner(&company) + if err != nil { + return fmt.Errorf("error creating company: %w", err) + } + l.Info("created company (parent)", "id", companyID) + + accounting.ParentId = odooclient.NewMany2One(companyID, "") + accountingID, err := session.CreateResPartner(&accounting) + if err != nil { + return fmt.Errorf("error creating accounting contact: %w", err) + } + l.Info("created accounting contact", "id", accountingID, "parent_id", companyID) + + // reset inflight flag + if err := session.Update(odooclient.ResPartnerModel, []int64{companyID, accountingID}, map[string]any{ + "vshn_control_api_inflight": false, + }); err != nil { + return fmt.Errorf("error resetting inflight flag: %w", err) + } + + nbe, err := s.Get(ctx, odooIDToK8sID(int(accountingID))) + if err != nil { + return fmt.Errorf("error fetching newly created billing entity: %w", err) + } + *be = *nbe + return nil +} + +func (s *Odoo16Storage) Update(ctx context.Context, be *billingv1.BillingEntity) error { + l := klog.FromContext(ctx) + + if be == nil { + return errors.New("billing entity is nil") + } + + company, accounting, err := mapBillingEntityToPartners(*be, s.config.CountryIDs) + if err != nil { + return fmt.Errorf("failed mapping billing entity to partners: %w", err) + } + + origCompany, origAccounting, err := s.get(ctx, be.Name) + if err != nil { + return fmt.Errorf("error fetching billing entity to update: %w", err) + } + + session, err := s.sessionCreator(ctx) + if err != nil { + return err + } + + company.Id = origCompany.Id + accounting.Id = origAccounting.Id + + if err := session.UpdateResPartner(&company); err != nil { + return fmt.Errorf("error updating company: %w", err) + } + l.Info("updated company (parent)", "id", origCompany.Id.Get()) + + if err := session.UpdateResPartner(&accounting); err != nil { + return fmt.Errorf("error updating accounting contact: %w", err) + } + l.Info("updated accounting contact", "id", origAccounting.Id.Get(), "parent_id", origCompany.Id.Get()) + + ube, err := s.Get(ctx, odooIDToK8sID(int(origAccounting.Id.Get()))) + if err != nil { + return fmt.Errorf("error fetching updated billing entity: %w", err) + } + *be = *ube + return nil +} + +// CleanupIncompleteRecords looks for partner records in Odoo that still have the "inflight" flag set despite being older than `minAge`. Those records are then deleted. +// Such records might come into existence due to a partially failed creation request. +func (s *FailedRecordScrubber) CleanupIncompleteRecords(ctx context.Context, minAge time.Duration) error { + l := klog.FromContext(ctx) + l.Info("Looking for stale inflight partner records...") + + session, err := s.sessionCreator(ctx) + if err != nil { + return err + } + + inflightRecords, err := session.FindResPartners(odooclient.NewCriteria().AddCriterion(mustInflightFilter), fetchPartnerFieldOpts) + if err != nil { + return err + } + + ids := []int64{} + + for _, record := range *inflightRecords { + createdTime := record.CreateDate.Get() + + if createdTime.Before(time.Now().Add(-1 * minAge)) { + ids = append(ids, record.Id.Get()) + l.Info("Preparing to delete inflight partner record", "name", record.Name, "id", record.Id.Get()) + } + } + + if len(ids) != 0 { + return session.DeleteResPartners(ids) + } + return nil +} + +func k8sIDToOdooID(id string) (int, error) { + if !strings.HasPrefix(id, "be-") { + return 0, fmt.Errorf("invalid ID, missing prefix: %s", id) + } + + return strconv.Atoi(id[3:]) +} + +func odooIDToK8sID(id int) string { + return fmt.Sprintf("be-%d", id) +} + +func mapPartnersToBillingEntity(ctx context.Context, company odooclient.ResPartner, accounting odooclient.ResPartner) billingv1.BillingEntity { + l := klog.FromContext(ctx) + name := odooIDToK8sID(int(accounting.Id.Get())) + + var status billingv1.BillingEntityStatus + if accounting.VshnControlApiMetaStatus.Get() != "" { + err := json.Unmarshal([]byte(accounting.VshnControlApiMetaStatus.Get()), &status) + + if err != nil { + l.Error(err, "Could not unmarshal BillingEntityStatus", "billingEntityName", name, "rawStatus", accounting.VshnControlApiMetaStatus.Get()) + } + } + + var country string + if company.CountryId != nil { + country = company.CountryId.Name + } + return billingv1.BillingEntity{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + CreationTimestamp: metav1.Time{ + Time: accounting.CreateDate.Get(), + }, + // Since Odoo does not reuse IDs AFAIK, we can use the id from Odoo as UID. + // Without UID patch operations will fail. + UID: types.UID(uuid.NewSHA1(metaUIDNamespace, []byte(name)).String()), + }, + Spec: billingv1.BillingEntitySpec{ + Name: company.Name.Get(), + Phone: company.Phone.Get(), + Emails: splitCommaSeparated(company.Email.Get()), + Address: billingv1.BillingEntityAddress{ + Line1: company.Street.Get(), + Line2: company.Street2.Get(), + City: company.City.Get(), + PostalCode: company.Zip.Get(), + Country: country, + }, + AccountingContact: billingv1.BillingEntityContact{ + Name: accounting.Name.Get(), + Emails: splitCommaSeparated(accounting.Email.Get()), + }, + LanguagePreference: "", + }, + Status: status, + } +} + +func mapBillingEntityToPartners(be billingv1.BillingEntity, countryIDs map[string]int) (company odooclient.ResPartner, accounting odooclient.ResPartner, err error) { + countryID, ok := countryIDs[be.Spec.Address.Country] + if !ok { + return company, accounting, fmt.Errorf("unknown country %q", be.Spec.Address.Country) + } + + st, err := json.Marshal(be.Status) + if err != nil { + return company, accounting, err + } + statusString := string(st) + + company = odooclient.ResPartner{ + Name: odooclient.NewString(be.Spec.Name), + Phone: odooclient.NewString(be.Spec.Phone), + + Street: odooclient.NewString(be.Spec.Address.Line1), + Street2: odooclient.NewString(be.Spec.Address.Line2), + City: odooclient.NewString(be.Spec.Address.City), + Zip: odooclient.NewString(be.Spec.Address.PostalCode), + CountryId: odooclient.NewMany2One(int64(countryID), ""), + Email: odooclient.NewString(strings.Join(be.Spec.Emails, ", ")), + } + + accounting = odooclient.ResPartner{ + Name: odooclient.NewString(be.Spec.AccountingContact.Name), + VshnControlApiMetaStatus: odooclient.NewString(statusString), + Email: odooclient.NewString(strings.Join(be.Spec.AccountingContact.Emails, ", ")), + } + + return company, accounting, nil +} + +func setStaticAccountingContactFields(conf Config, a *odooclient.ResPartner) { + a.CategoryId = odooclient.NewRelation() + a.CategoryId.AddRecord(int64(roleAccountCategory)) + a.Lang = odooclient.NewSelection(conf.LanguagePreference) + a.Type = odooclient.NewSelection(invoiceType) + a.PropertyPaymentTermId = odooclient.NewMany2One(int64(conf.PaymentTermID), "") +} + +func setStaticCompanyFields(conf Config, a *odooclient.ResPartner) { + a.CategoryId = odooclient.NewRelation() + a.CategoryId.AddRecord(int64(companyCategory)) + a.Lang = odooclient.NewSelection(conf.LanguagePreference) + a.PropertyPaymentTermId = odooclient.NewMany2One(int64(conf.PaymentTermID), "") +} + +func splitCommaSeparated(s string) []string { + if s == "" { + return []string{} + } + p := strings.Split(s, ",") + for i, v := range p { + p[i] = strings.TrimSpace(v) + } + return p +} diff --git a/apiserver/billing/odoostorage/odoo/odoo16/odoo16_test.go b/apiserver/billing/odoostorage/odoo/odoo16/odoo16_test.go new file mode 100644 index 00000000..a3572d31 --- /dev/null +++ b/apiserver/billing/odoostorage/odoo/odoo16/odoo16_test.go @@ -0,0 +1,401 @@ +package odoo16 + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + billingv1 "github.com/appuio/control-api/apis/billing/v1" + "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo16/odoo16mock" + odooclient "github.com/appuio/go-odoo" +) + +func TestGet(t *testing.T) { + ctrl, mock, subject := createStorage(t) + defer ctrl.Finish() + + tn := time.Now() + st, _ := time.Parse(time.RFC3339, "2023-04-18T14:07:55Z") + statusTime := st.Local() + + gomock.InOrder( + mock.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{ + Id: odooclient.NewInt(456), + CreateDate: odooclient.NewTime(tn), + ParentId: odooclient.NewMany2One(123, ""), + Email: odooclient.NewString("accounting@test.com, notifications@test.com"), + VshnControlApiMetaStatus: odooclient.NewString("{\"conditions\":[{\"type\":\"ConditionFoo\",\"status\":\"False\",\"lastTransitionTime\":\"" + statusTime.Format(time.RFC3339) + "\",\"reason\":\"Whatever\",\"message\":\"Hello World\"}]}"), + }}).Return(nil), + mock.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{ + Id: odooclient.NewInt(123), + Name: odooclient.NewString("Test Company"), + }}).Return(nil), + ) + + s, err := subject.Get(context.Background(), "be-456") + require.NoError(t, err) + assert.Equal(t, &billingv1.BillingEntity{ + ObjectMeta: metav1.ObjectMeta{ + Name: "be-456", + UID: "8804e682-706b-5f22-83bc-3564dadd08e1", + CreationTimestamp: metav1.Time{Time: tn}, + }, + Spec: billingv1.BillingEntitySpec{ + Name: "Test Company", + Emails: []string{}, + AccountingContact: billingv1.BillingEntityContact{ + Emails: []string{ + "accounting@test.com", + "notifications@test.com", + }, + }, + }, + Status: billingv1.BillingEntityStatus{ + Conditions: []metav1.Condition{ + { + Type: "ConditionFoo", + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.NewTime(statusTime), + Reason: "Whatever", + Message: "Hello World", + }, + }, + }, + }, s) +} + +func TestInvalidID(t *testing.T) { + ctrl, _, subject := createStorage(t) + defer ctrl.Finish() + + _, err := subject.Get(context.Background(), "456") + require.Error(t, err) + _, err = subject.Get(context.Background(), "sdf=456") + require.Error(t, err) +} + +func TestGetNoParent(t *testing.T) { + ctrl, mock, subject := createStorage(t) + defer ctrl.Finish() + + gomock.InOrder( + mock.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{ + Id: odooclient.NewInt(456), + Name: odooclient.NewString("Accounting"), + }}, + ).Return(nil), + ) + + _, err := subject.Get(context.Background(), "be-456") + require.Error(t, err) +} + +func TestGet_ParentCantBeRetrieved(t *testing.T) { + ctrl, mock, subject := createStorage(t) + defer ctrl.Finish() + + gomock.InOrder( + mock.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{ + Id: odooclient.NewInt(456), + Name: odooclient.NewString("Accounting"), + ParentId: odooclient.NewMany2One(123, ""), + }}).Return(nil), + mock.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("No such record")), + ) + + _, err := subject.Get(context.Background(), "be-456") + require.ErrorContains(t, err, "fetching parent 123 of accounting contact 456 failed") +} + +func TestList(t *testing.T) { + ctrl, mock, subject := createStorage(t) + defer ctrl.Finish() + + gomock.InOrder( + mock.EXPECT().FindResPartners(gomock.Any(), gomock.Any()).Return(&odooclient.ResPartners{ + { + Id: odooclient.NewInt(456), + ParentId: odooclient.NewMany2One(123, ""), + }, + { + Id: odooclient.NewInt(457), + ParentId: odooclient.NewMany2One(124, ""), + }, + { + // Can't load parent + Id: odooclient.NewInt(458), + ParentId: odooclient.NewMany2One(99999, ""), + }, + { + // No parent + Id: odooclient.NewInt(459), + ParentId: nil, + }, + }, nil), + mock.EXPECT().FindResPartners(gomock.Any(), gomock.Any()).Return(&odooclient.ResPartners{ + {Id: odooclient.NewInt(123), Name: odooclient.NewString("Test Company")}, + {Id: odooclient.NewInt(124), Name: odooclient.NewString("Foo Company")}, + }, nil), + ) + + s, err := subject.List(context.Background()) + require.NoError(t, err) + assert.Equal(t, []billingv1.BillingEntity{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "be-456", + UID: "8804e682-706b-5f22-83bc-3564dadd08e1", + }, + Spec: billingv1.BillingEntitySpec{ + Name: "Test Company", + Emails: []string{}, + AccountingContact: billingv1.BillingEntityContact{ + Emails: []string{}, + }, + }, + }, { + ObjectMeta: metav1.ObjectMeta{ + Name: "be-457", + UID: "cdb1442c-2444-5cde-8f07-7ebfa7e8825f", + }, + Spec: billingv1.BillingEntitySpec{ + Name: "Foo Company", + Emails: []string{}, + AccountingContact: billingv1.BillingEntityContact{ + Emails: []string{}, + }, + }, + }, + }, s) +} + +func TestCreate(t *testing.T) { + ctrl, mock, subject := createStorage(t) + defer ctrl.Finish() + + tn := time.Now() + + gomock.InOrder( + // Create company (parent) + mock.EXPECT().CreateResPartner(gomock.Any()).Return(int64(700), nil), + // Create accounting contact + mock.EXPECT().CreateResPartner(gomock.Any()).Return(int64(702), nil), + // Reset inflight flag + mock.EXPECT().Update(odooclient.ResPartnerModel, gomock.InAnyOrder([]int64{700, 702}), gomock.Any()), + // Fetch created company + mock.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{ + Id: odooclient.NewInt(702), + Name: odooclient.NewString("Max Foobar"), + CreateDate: odooclient.NewTime(tn), + ParentId: odooclient.NewMany2One(700, ""), + Email: odooclient.NewString("accounting@test.com, notifications@test.com"), + }}), + mock.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{ + Id: odooclient.NewInt(700), + Name: odooclient.NewString("Test Company"), + }}), + ) + + s := &billingv1.BillingEntity{ + Spec: billingv1.BillingEntitySpec{ + Name: "Test Company", + }, + } + err := subject.Create(context.Background(), s) + require.NoError(t, err) + assert.Equal(t, &billingv1.BillingEntity{ + ObjectMeta: metav1.ObjectMeta{ + Name: "be-702", + UID: "5ff3b076-7648-51bf-b46d-ed96cfc6f43b", + CreationTimestamp: metav1.Time{Time: tn}, + }, + Spec: billingv1.BillingEntitySpec{ + Name: "Test Company", + Emails: []string{}, + AccountingContact: billingv1.BillingEntityContact{ + Name: "Max Foobar", + Emails: []string{ + "accounting@test.com", + "notifications@test.com", + }, + }, + }, + }, s) +} + +func TestUpdate(t *testing.T) { + ctrl, mock, subject := createStorage(t) + defer ctrl.Finish() + + tn := time.Now() + st, _ := time.Parse(time.RFC3339, "2023-04-18T14:07:55Z") + statusTime := st.Local() + + gomock.InOrder( + // Fetch existing company + mock.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{ + Id: odooclient.NewInt(702), + ParentId: odooclient.NewMany2One(700, ""), + }}), + mock.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{ + Id: odooclient.NewInt(700), + Name: odooclient.NewString("Test Company"), + }}), + // Update company + mock.EXPECT().UpdateResPartner(gomock.Any()), + // Update accounting contact + mock.EXPECT().UpdateResPartner(gomock.Any()), + // Fetch created company + mock.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{ + Id: odooclient.NewInt(702), + CreateDate: odooclient.NewTime(tn), + ParentId: odooclient.NewMany2One(700, ""), + Email: odooclient.NewString("accounting@test.com, notifications@test.com"), + VshnControlApiMetaStatus: odooclient.NewString("{\"conditions\":[{\"type\":\"ConditionFoo\",\"status\":\"False\",\"lastTransitionTime\":\"" + statusTime.Format(time.RFC3339) + "\",\"reason\":\"Whatever\",\"message\":\"Hello World\"}]}"), + }}), + mock.EXPECT().Read(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).SetArg(3, []odooclient.ResPartner{{ + Id: odooclient.NewInt(700), + Name: odooclient.NewString("Test Company"), + }}), + ) + + s := &billingv1.BillingEntity{ + ObjectMeta: metav1.ObjectMeta{ + Name: "be-702", + }, + Spec: billingv1.BillingEntitySpec{ + Name: "Test Company", + }, + Status: billingv1.BillingEntityStatus{ + Conditions: []metav1.Condition{ + { + Type: "ConditionFoo", + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.NewTime(statusTime), + Reason: "Whatever", + Message: "Hello World", + }, + }, + }, + } + err := subject.Update(context.Background(), s) + require.NoError(t, err) + assert.Equal(t, &billingv1.BillingEntity{ + ObjectMeta: metav1.ObjectMeta{ + Name: "be-702", + UID: "5ff3b076-7648-51bf-b46d-ed96cfc6f43b", + CreationTimestamp: metav1.Time{Time: tn}, + }, + Spec: billingv1.BillingEntitySpec{ + Name: "Test Company", + Emails: []string{}, + AccountingContact: billingv1.BillingEntityContact{ + Emails: []string{ + "accounting@test.com", + "notifications@test.com", + }, + }, + }, + Status: billingv1.BillingEntityStatus{ + Conditions: []metav1.Condition{ + { + Type: "ConditionFoo", + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.NewTime(statusTime), + Reason: "Whatever", + Message: "Hello World", + }, + }, + }, + }, s) +} + +func Test_CreateUpdate_UnknownCountry(t *testing.T) { + ctrl, _, subject := createStorage(t) + defer ctrl.Finish() + + s := &billingv1.BillingEntity{ + ObjectMeta: metav1.ObjectMeta{ + Name: "be-702", + }, + Spec: billingv1.BillingEntitySpec{ + Address: billingv1.BillingEntityAddress{ + Country: "Vatican City", + }, + }, + } + require.ErrorContains(t, subject.Create(context.Background(), s), "unknown country") + require.ErrorContains(t, subject.Update(context.Background(), s), "unknown country") +} + +func createStorage(t *testing.T) (*gomock.Controller, *odoo16mock.MockOdoo16Client, *Odoo16Storage) { + ctrl := gomock.NewController(t) + mock := odoo16mock.NewMockOdoo16Client(ctrl) + + return ctrl, mock, &Odoo16Storage{ + config: Config{ + CountryIDs: map[string]int{ + "": 0, + "Switzerland": 1, + "Germany": 2, + }, + LanguagePreference: "en_US", + PaymentTermID: 2, + }, + sessionCreator: func(ctx context.Context) (Odoo16Client, error) { + return mock, nil + }, + } +} + +func createFailedRecordScrubber(t *testing.T) (*gomock.Controller, *odoo16mock.MockOdoo16Client, *FailedRecordScrubber) { + ctrl := gomock.NewController(t) + mock := odoo16mock.NewMockOdoo16Client(ctrl) + + return ctrl, mock, &FailedRecordScrubber{ + sessionCreator: func(ctx context.Context) (Odoo16Client, error) { + return mock, nil + }, + } +} + +func TestCleanup(t *testing.T) { + ctrl, mock, subject := createFailedRecordScrubber(t) + defer ctrl.Finish() + + tn := time.Now() + to := tn.Add(time.Hour * -1) + + gomock.InOrder( + // Fetch stale records + mock.EXPECT().FindResPartners(gomock.Any(), gomock.Any()).Return(&odooclient.ResPartners{ + { + Id: odooclient.NewInt(702), + Name: odooclient.NewString("Accounting"), + CreateDate: odooclient.NewTime(tn), + ParentId: odooclient.NewMany2One(700, ""), + Email: odooclient.NewString("accounting@test.com, notifications@test.com"), + VshnControlApiInflight: odooclient.NewString("fooo"), + }, + { + Id: odooclient.NewInt(703), + Name: odooclient.NewString("Accounting"), + CreateDate: odooclient.NewTime(to), + ParentId: odooclient.NewMany2One(700, ""), + Email: odooclient.NewString("accounting@test.com, notifications@test.com"), + VshnControlApiInflight: odooclient.NewString("fooo"), + }, + }, nil), + mock.EXPECT().DeleteResPartners(gomock.Eq([]int64{703})).Return(nil), + ) + + err := subject.CleanupIncompleteRecords(context.Background(), time.Minute) + require.NoError(t, err) + +} diff --git a/apiserver/billing/odoostorage/odoo/odoo16/odoo16mock/mock.go b/apiserver/billing/odoostorage/odoo/odoo16/odoo16mock/mock.go new file mode 100644 index 00000000..882bff8d --- /dev/null +++ b/apiserver/billing/odoostorage/odoo/odoo16/odoo16mock/mock.go @@ -0,0 +1,125 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: apiserver/billing/odoostorage/odoo/odoo16/odoo16.go +// +// Generated by this command: +// +// mockgen -source=apiserver/billing/odoostorage/odoo/odoo16/odoo16.go +// +// Package odoo16mock is a generated GoMock package. +package odoo16mock + +import ( + reflect "reflect" + + go_odoo "github.com/appuio/go-odoo" + gomock "go.uber.org/mock/gomock" +) + +// MockOdoo16Client is a mock of Odoo16Client interface. +type MockOdoo16Client struct { + ctrl *gomock.Controller + recorder *MockOdoo16ClientMockRecorder +} + +// MockOdoo16ClientMockRecorder is the mock recorder for MockOdoo16Client. +type MockOdoo16ClientMockRecorder struct { + mock *MockOdoo16Client +} + +// NewMockOdoo16Client creates a new mock instance. +func NewMockOdoo16Client(ctrl *gomock.Controller) *MockOdoo16Client { + mock := &MockOdoo16Client{ctrl: ctrl} + mock.recorder = &MockOdoo16ClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockOdoo16Client) EXPECT() *MockOdoo16ClientMockRecorder { + return m.recorder +} + +// CreateResPartner mocks base method. +func (m *MockOdoo16Client) CreateResPartner(arg0 *go_odoo.ResPartner) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateResPartner", arg0) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateResPartner indicates an expected call of CreateResPartner. +func (mr *MockOdoo16ClientMockRecorder) CreateResPartner(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateResPartner", reflect.TypeOf((*MockOdoo16Client)(nil).CreateResPartner), arg0) +} + +// DeleteResPartners mocks base method. +func (m *MockOdoo16Client) DeleteResPartners(arg0 []int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteResPartners", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteResPartners indicates an expected call of DeleteResPartners. +func (mr *MockOdoo16ClientMockRecorder) DeleteResPartners(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteResPartners", reflect.TypeOf((*MockOdoo16Client)(nil).DeleteResPartners), arg0) +} + +// FindResPartners mocks base method. +func (m *MockOdoo16Client) FindResPartners(arg0 *go_odoo.Criteria, arg1 *go_odoo.Options) (*go_odoo.ResPartners, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindResPartners", arg0, arg1) + ret0, _ := ret[0].(*go_odoo.ResPartners) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindResPartners indicates an expected call of FindResPartners. +func (mr *MockOdoo16ClientMockRecorder) FindResPartners(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindResPartners", reflect.TypeOf((*MockOdoo16Client)(nil).FindResPartners), arg0, arg1) +} + +// Read mocks base method. +func (m *MockOdoo16Client) Read(arg0 string, arg1 []int64, arg2 *go_odoo.Options, arg3 any) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Read", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// Read indicates an expected call of Read. +func (mr *MockOdoo16ClientMockRecorder) Read(arg0, arg1, arg2, arg3 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockOdoo16Client)(nil).Read), arg0, arg1, arg2, arg3) +} + +// Update mocks base method. +func (m *MockOdoo16Client) Update(arg0 string, arg1 []int64, arg2 any) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockOdoo16ClientMockRecorder) Update(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockOdoo16Client)(nil).Update), arg0, arg1, arg2) +} + +// UpdateResPartner mocks base method. +func (m *MockOdoo16Client) UpdateResPartner(arg0 *go_odoo.ResPartner) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateResPartner", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateResPartner indicates an expected call of UpdateResPartner. +func (mr *MockOdoo16ClientMockRecorder) UpdateResPartner(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateResPartner", reflect.TypeOf((*MockOdoo16Client)(nil).UpdateResPartner), arg0) +} diff --git a/apiserver/billing/odoostorage/odoostorage.go b/apiserver/billing/odoostorage/odoostorage.go index cca49e05..5f79a07e 100644 --- a/apiserver/billing/odoostorage/odoostorage.go +++ b/apiserver/billing/odoostorage/odoostorage.go @@ -7,6 +7,7 @@ import ( billingv1 "github.com/appuio/control-api/apis/billing/v1" "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo" "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/fake" + "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo16" "github.com/appuio/control-api/apiserver/billing/odoostorage/odoo/odoo8" ) @@ -24,6 +25,13 @@ func NewOdoo8Storage(odooURL string, debugTransport bool, conf odoo8.Config) Sto } } +// NewOdoo16Storage returns a new storage provider for BillingEntities +func NewOdoo16Storage(credentials odoo16.OdooCredentials, config odoo16.Config) Storage { + return &billingEntityStorage{ + storage: odoo16.NewOdoo16Storage(credentials, config), + } +} + type billingEntityStorage struct { storage odoo.OdooStorage } diff --git a/go.mod b/go.mod index da26ea7c..c5734b5d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/Masterminds/sprig/v3 v3.2.3 + github.com/appuio/go-odoo v0.3.0 github.com/go-logr/zapr v1.2.3 github.com/golang/mock v1.6.0 github.com/google/uuid v1.3.0 @@ -11,6 +12,7 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.2 + go.uber.org/mock v0.3.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.24.0 golang.org/x/exp v0.0.0-20230307190834-24139beb5833 @@ -30,6 +32,7 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/huandu/xstrings v1.3.3 // indirect + github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect @@ -113,7 +116,7 @@ require ( go.starlark.net v0.0.0-20230103143115-09991d3a103e // indirect go.uber.org/atomic v1.10.0 // indirect golang.org/x/crypto v0.5.0 // indirect - golang.org/x/mod v0.7.0 // indirect + golang.org/x/mod v0.11.0 // indirect golang.org/x/net v0.7.0 // indirect golang.org/x/oauth2 v0.5.0 // indirect golang.org/x/sync v0.1.0 // indirect diff --git a/go.sum b/go.sum index b13882b0..9fb2f164 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPp github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 h1:yL7+Jz0jTC6yykIK/Wh74gnTJnrGr5AyrNMXuA0gves= github.com/antlr/antlr4/runtime/Go/antlr v1.4.10/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY= +github.com/appuio/go-odoo v0.3.0 h1:SR53UYq7wiTR1LHDZy63LV4L6uFIKPZYOIzUd+CvGzU= +github.com/appuio/go-odoo v0.3.0/go.mod h1:pN7SdgIUWAS6hMW0L99FaofBG+5pSUA77vRcUT0mxjY= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -279,6 +281,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= +github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -458,6 +462,8 @@ go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo= +go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= @@ -510,8 +516,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= -golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=