From 8026bac4df5088281b2d55b65a1d4dc92938454e Mon Sep 17 00:00:00 2001 From: Aline Abler Date: Tue, 5 Dec 2023 17:43:56 +0100 Subject: [PATCH 1/2] Reconcile organizations to create sales orders where needed --- apis/organization/v1/organization_types.go | 59 ++++- apis/organization/v1/zz_generated.deepcopy.go | 10 +- config/rbac/controller/role.yaml | 16 ++ controller.go | 37 +++ controllers/sale_order_controller.go | 102 ++++++++ controllers/sale_order_controller_test.go | 243 ++++++++++++++++++ controllers/saleorder/mock_saleorder/mock.go | 122 +++++++++ controllers/saleorder/saleorder_storage.go | 129 ++++++++++ .../saleorder/saleorder_storage_test.go | 140 ++++++++++ go.mod | 2 +- go.sum | 4 +- 11 files changed, 859 insertions(+), 5 deletions(-) create mode 100644 controllers/sale_order_controller.go create mode 100644 controllers/sale_order_controller_test.go create mode 100644 controllers/saleorder/mock_saleorder/mock.go create mode 100644 controllers/saleorder/saleorder_storage.go create mode 100644 controllers/saleorder/saleorder_storage_test.go diff --git a/apis/organization/v1/organization_types.go b/apis/organization/v1/organization_types.go index a84c348d..762e6873 100644 --- a/apis/organization/v1/organization_types.go +++ b/apis/organization/v1/organization_types.go @@ -1,6 +1,8 @@ package v1 import ( + "encoding/json" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" @@ -8,6 +10,18 @@ import ( "sigs.k8s.io/apiserver-runtime/pkg/builder/resource" ) +const ( + // SaleOrderCreated is set when the Sale Order has been created + ConditionSaleOrderCreated = "SaleOrderCreated" + + // SaleOrderNameUpdated is set when the Sale Order's name has been added to the Status + ConditionSaleOrderNameUpdated = "SaleOrderNameUpdated" + + ConditionReasonCreateFailed = "CreateFailed" + + ConditionReasonGetNameFailed = "GetNameFailed" +) + // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create;delete;update var ( @@ -21,6 +35,12 @@ var ( BillingEntityRefKey = "organization.appuio.io/billing-entity-ref" // BillingEntityNameKey is the annotation key that stores the billing entity name BillingEntityNameKey = "status.organization.appuio.io/billing-entity-name" + // SaleOrderIdKey is the annotation key that stores the sale order ID + SaleOrderIdKey = "status.organization.appuio.io/sale-order-id" + // SaleOrderNameKey is the annotation key that stores the sale order name + SaleOrderNameKey = "status.organization.appuio.io/sale-order-name" + // StatusConditionsKey is the annotation key that stores the serialized status conditions + StatusConditionsKey = "status.organization.appuio.io/conditions" ) // NewOrganizationFromNS returns an Organization based on the given namespace @@ -29,11 +49,19 @@ func NewOrganizationFromNS(ns *corev1.Namespace) *Organization { if ns == nil || ns.Labels == nil || ns.Labels[TypeKey] != OrgType { return nil } - var displayName, billingEntityRef, billingEntityName string + var displayName, billingEntityRef, billingEntityName, saleOrderId, saleOrderName, statusConditionsString string if ns.Annotations != nil { displayName = ns.Annotations[DisplayNameKey] billingEntityRef = ns.Annotations[BillingEntityRefKey] billingEntityName = ns.Annotations[BillingEntityNameKey] + statusConditionsString = ns.Annotations[StatusConditionsKey] + saleOrderId = ns.Annotations[SaleOrderIdKey] + saleOrderName = ns.Annotations[SaleOrderNameKey] + } + var conditions []metav1.Condition + err := json.Unmarshal([]byte(statusConditionsString), &conditions) + if err != nil { + conditions = nil } org := &Organization{ ObjectMeta: *ns.ObjectMeta.DeepCopy(), @@ -43,6 +71,9 @@ func NewOrganizationFromNS(ns *corev1.Namespace) *Organization { }, Status: OrganizationStatus{ BillingEntityName: billingEntityName, + SaleOrderID: saleOrderId, + SaleOrderName: saleOrderName, + Conditions: conditions, }, } if org.Annotations != nil { @@ -79,6 +110,15 @@ type OrganizationSpec struct { type OrganizationStatus struct { // BillingEntityName is the name of the billing entity BillingEntityName string `json:"billingEntityName,omitempty"` + + // SaleOrderID is the ID of the sale order + SaleOrderID string `json:"saleOrderId,omitempty"` + + // SaleOrderName is the name of the sale order + SaleOrderName string `json:"saleOrderName,omitempty"` + + // Conditions is a list of conditions for the invitation + Conditions []metav1.Condition `json:"conditions,omitempty"` } // Organization needs to implement the builder resource interface @@ -149,10 +189,27 @@ func (o *Organization) ToNamespace() *corev1.Namespace { if ns.Annotations == nil { ns.Annotations = map[string]string{} } + var statusString string + if o.Status.Conditions != nil { + statusBytes, err := json.Marshal(o.Status.Conditions) + if err == nil { + statusString = string(statusBytes) + } + } + ns.Labels[TypeKey] = OrgType ns.Annotations[DisplayNameKey] = o.Spec.DisplayName ns.Annotations[BillingEntityRefKey] = o.Spec.BillingEntityRef ns.Annotations[BillingEntityNameKey] = o.Status.BillingEntityName + if o.Status.SaleOrderID != "" { + ns.Annotations[SaleOrderIdKey] = o.Status.SaleOrderID + } + if o.Status.SaleOrderName != "" { + ns.Annotations[SaleOrderNameKey] = o.Status.SaleOrderName + } + if statusString != "" { + ns.Annotations[StatusConditionsKey] = statusString + } return ns } diff --git a/apis/organization/v1/zz_generated.deepcopy.go b/apis/organization/v1/zz_generated.deepcopy.go index 26fc77f8..10a9ed70 100644 --- a/apis/organization/v1/zz_generated.deepcopy.go +++ b/apis/organization/v1/zz_generated.deepcopy.go @@ -6,6 +6,7 @@ package v1 import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -15,7 +16,7 @@ func (in *Organization) DeepCopyInto(out *Organization) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) out.Spec = in.Spec - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Organization. @@ -86,6 +87,13 @@ func (in *OrganizationSpec) DeepCopy() *OrganizationSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OrganizationStatus) DeepCopyInto(out *OrganizationStatus) { *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OrganizationStatus. diff --git a/config/rbac/controller/role.yaml b/config/rbac/controller/role.yaml index 2e56895f..0f7f48d6 100644 --- a/config/rbac/controller/role.yaml +++ b/config/rbac/controller/role.yaml @@ -226,3 +226,19 @@ rules: - get - patch - update +- apiGroups: + - user.appuio.io + resources: + - organizations + verbs: + - get + - list + - watch +- apiGroups: + - user.appuio.io + resources: + - organizations/status + verbs: + - get + - patch + - update diff --git a/controller.go b/controller.go index 6041939d..d65e43fc 100644 --- a/controller.go +++ b/controller.go @@ -28,6 +28,7 @@ import ( orgv1 "github.com/appuio/control-api/apis/organization/v1" userv1 "github.com/appuio/control-api/apis/user/v1" controlv1 "github.com/appuio/control-api/apis/v1" + "github.com/appuio/control-api/controllers/saleorder" "github.com/appuio/control-api/mailsenders" "github.com/appuio/control-api/controllers" @@ -66,6 +67,7 @@ func ControllerCommand() *cobra.Command { zapfs := flag.NewFlagSet("zap", flag.ExitOnError) opts := zap.Options{} + oc := saleorder.Odoo16Credentials{} opts.BindFlags(zapfs) cmd.Flags().AddGoFlagSet(zapfs) @@ -102,6 +104,14 @@ func ControllerCommand() *cobra.Command { billingEntityEmailSubject := cmd.Flags().String("billingentity-email-subject", "An APPUiO Billing Entity has been updated", "Subject for billing entity modification update mails") billingEntityCronInterval := cmd.Flags().String("billingentity-email-cron-interval", "@every 1h", "Cron interval for how frequently billing entity update e-mails are sent") + saleOrderStorage := cmd.Flags().String("sale-order-storage", "none", "Type of sale order storage to use. Valid values are `none` and `odoo16`") + saleOrderClientReference := cmd.Flags().String("sale-order-client-reference", "APPUiO Cloud", "Default client reference to add to newly created sales orders.") + saleOrderInternalNote := cmd.Flags().String("sale-order-internal-note", "auto-generated by APPUiO Cloud Control API", "Default internal note to add to newly created sales orders.") + cmd.Flags().StringVar(&oc.URL, "sale-order-odoo16-url", "http://localhost:8069", "URL of the Odoo instance to use for sale orders") + cmd.Flags().StringVar(&oc.Database, "sale-order-odoo16-db", "odooDB", "Database of the Odoo instance to use for sale orders") + cmd.Flags().StringVar(&oc.Admin, "sale-order-odoo16-account", "Admin", "Odoo Account name to use for sale orders") + cmd.Flags().StringVar(&oc.Password, "sale-order-odoo16-password", "superSecret1238", "Odoo Account password to use for sale orders") + cmd.Run = func(*cobra.Command, []string) { scheme := runtime.NewScheme() setupLog := ctrl.Log.WithName("setup") @@ -182,6 +192,10 @@ func ControllerCommand() *cobra.Command { *redeemedInvitationTTL, *invEmailBaseRetryDelay, invMailSender, + *saleOrderStorage, + *saleOrderClientReference, + *saleOrderInternalNote, + oc, ctrl.Options{ Scheme: scheme, MetricsBindAddress: *metricsAddr, @@ -228,6 +242,10 @@ func setupManager( redeemedInvitationTTL time.Duration, invEmailBaseRetryDelay time.Duration, mailSender mailsenders.MailSender, + saleOrderStorage string, + saleOrderClientReference string, + saleOrderInternalNote string, + odooCredentials saleorder.Odoo16Credentials, opt ctrl.Options, ) (ctrl.Manager, error) { mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), opt) @@ -320,6 +338,25 @@ func setupManager( return nil, err } + if saleOrderStorage == "odoo16" { + storage, err := saleorder.NewOdoo16Storage(&odooCredentials, &saleorder.Odoo16Options{ + SaleOrderClientReferencePrefix: saleOrderClientReference, + SaleOrderInternalNote: saleOrderInternalNote, + }) + if err != nil { + return nil, err + } + saleorder := &controllers.SaleOrderReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("sale-order-controller"), + SaleOrderStorage: storage, + } + if err = saleorder.SetupWithManager(mgr); err != nil { + return nil, err + } + } + metrics.Registry.MustRegister(invmail.GetMetrics()) mgr.GetWebhookServer().Register("/validate-appuio-io-v1-user", &webhook.Admission{ diff --git a/controllers/sale_order_controller.go b/controllers/sale_order_controller.go new file mode 100644 index 00000000..9a8d627b --- /dev/null +++ b/controllers/sale_order_controller.go @@ -0,0 +1,102 @@ +package controllers + +import ( + "context" + "fmt" + + "go.uber.org/multierr" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + organizationv1 "github.com/appuio/control-api/apis/organization/v1" + "github.com/appuio/control-api/controllers/saleorder" +) + +// SaleOrderReconciler reconciles invitations and adds a token to the status if required. +type SaleOrderReconciler struct { + client.Client + + Recorder record.EventRecorder + Scheme *runtime.Scheme + + SaleOrderStorage saleorder.SaleOrderStorage +} + +//+kubebuilder:rbac:groups="rbac.appuio.io",resources=organizations,verbs=get;list;watch +//+kubebuilder:rbac:groups="user.appuio.io",resources=organizations,verbs=get;list;watch +//+kubebuilder:rbac:groups="rbac.appuio.io",resources=organizations/status,verbs=get;update;patch +//+kubebuilder:rbac:groups="user.appuio.io",resources=organizations/status,verbs=get;update;patch + +// Reconcile reacts to Organizations and creates Sale Orders if necessary +func (r *SaleOrderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + log.V(1).WithValues("request", req).Info("Reconciling") + + org := organizationv1.Organization{} + if err := r.Get(ctx, req.NamespacedName, &org); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if org.Spec.BillingEntityRef == "" { + return ctrl.Result{}, nil + } + + if org.Status.SaleOrderName != "" { + return ctrl.Result{}, nil + } + + if org.Status.SaleOrderID != "" { + // ID is present, but Name is not. Update name. + soName, err := r.SaleOrderStorage.GetSaleOrderName(org) + if err != nil { + log.V(0).Error(err, "Error getting sale order name") + apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{ + Type: organizationv1.ConditionSaleOrderNameUpdated, + Status: metav1.ConditionFalse, + Reason: organizationv1.ConditionReasonGetNameFailed, + Message: err.Error(), + }) + return ctrl.Result{}, multierr.Append(err, r.Client.Status().Update(ctx, &org)) + } + apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{ + Type: organizationv1.ConditionSaleOrderNameUpdated, + Status: metav1.ConditionTrue, + }) + org.Status.SaleOrderName = soName + return ctrl.Result{}, r.Client.Status().Update(ctx, &org) + } + + // Neither ID nor Name is present. Create new SO. + soId, err := r.SaleOrderStorage.CreateSaleOrder(org) + + if err != nil { + log.V(0).Error(err, "Error creating sale order") + apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{ + Type: organizationv1.ConditionSaleOrderCreated, + Status: metav1.ConditionFalse, + Reason: organizationv1.ConditionReasonCreateFailed, + Message: err.Error(), + }) + return ctrl.Result{}, multierr.Append(err, r.Client.Status().Update(ctx, &org)) + } + + apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{ + Type: organizationv1.ConditionSaleOrderCreated, + Status: metav1.ConditionTrue, + }) + + org.Status.SaleOrderID = fmt.Sprint(soId) + return ctrl.Result{}, r.Client.Status().Update(ctx, &org) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *SaleOrderReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&organizationv1.Organization{}). + Complete(r) +} diff --git a/controllers/sale_order_controller_test.go b/controllers/sale_order_controller_test.go new file mode 100644 index 00000000..009be858 --- /dev/null +++ b/controllers/sale_order_controller_test.go @@ -0,0 +1,243 @@ +package controllers_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + + organizationv1 "github.com/appuio/control-api/apis/organization/v1" + . "github.com/appuio/control-api/controllers" + "github.com/appuio/control-api/controllers/saleorder/mock_saleorder" +) + +func Test_SaleOrderReconciler_Reconcile_Create_Success(t *testing.T) { + ctx := context.Background() + mctrl := gomock.NewController(t) + mock := mock_saleorder.NewMockSaleOrderStorage(mctrl) + + subject := organizationv1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "subject", + }, + Spec: organizationv1.OrganizationSpec{ + BillingEntityRef: "be-0000", + }, + } + c := prepareTest(t, &subject) + + gomock.InOrder( + mock.EXPECT().CreateSaleOrder(gomock.Any()).Return("123", nil), + ) + + _, err := (&SaleOrderReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: record.NewFakeRecorder(3), + SaleOrderStorage: mock, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: subject.Name, + }, + }) + + require.NoError(t, err) + require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject)) + require.Equal(t, "123", subject.Status.SaleOrderID) + cond := apimeta.FindStatusCondition(subject.Status.Conditions, organizationv1.ConditionSaleOrderCreated) + require.Equal(t, metav1.ConditionTrue, cond.Status) +} + +func Test_SaleOrderReconciler_Reconcile_UpdateName_Success(t *testing.T) { + ctx := context.Background() + mctrl := gomock.NewController(t) + mock := mock_saleorder.NewMockSaleOrderStorage(mctrl) + + subject := organizationv1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "subject", + }, + Spec: organizationv1.OrganizationSpec{ + BillingEntityRef: "be-0000", + }, + Status: organizationv1.OrganizationStatus{ + SaleOrderID: "123", + }, + } + c := prepareTest(t, &subject) + + gomock.InOrder( + mock.EXPECT().GetSaleOrderName(gomock.Any()).Return("SO123", nil), + ) + + _, err := (&SaleOrderReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: record.NewFakeRecorder(3), + SaleOrderStorage: mock, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: subject.Name, + }, + }) + + require.NoError(t, err) + require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject)) + require.Equal(t, "123", subject.Status.SaleOrderID) + require.Equal(t, "SO123", subject.Status.SaleOrderName) + cond := apimeta.FindStatusCondition(subject.Status.Conditions, organizationv1.ConditionSaleOrderNameUpdated) + require.Equal(t, metav1.ConditionTrue, cond.Status) +} + +func Test_SaleOrderReconciler_Reconcile_NoAction_Success(t *testing.T) { + ctx := context.Background() + mctrl := gomock.NewController(t) + mock := mock_saleorder.NewMockSaleOrderStorage(mctrl) + + subject := organizationv1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "subject", + }, + Spec: organizationv1.OrganizationSpec{ + BillingEntityRef: "be-0000", + }, + Status: organizationv1.OrganizationStatus{ + SaleOrderID: "123", + SaleOrderName: "SO123", + }, + } + c := prepareTest(t, &subject) + + mock.EXPECT().CreateSaleOrder(gomock.Any()).Times(0) + mock.EXPECT().GetSaleOrderName(gomock.Any()).Times(0) + + _, err := (&SaleOrderReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: record.NewFakeRecorder(3), + SaleOrderStorage: mock, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: subject.Name, + }, + }) + + require.NoError(t, err) + require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject)) +} + +func Test_SaleOrderReconciler_Reconcile_NoBillingEntity_Success(t *testing.T) { + ctx := context.Background() + mctrl := gomock.NewController(t) + mock := mock_saleorder.NewMockSaleOrderStorage(mctrl) + + subject := organizationv1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "subject", + }, + } + c := prepareTest(t, &subject) + + mock.EXPECT().CreateSaleOrder(gomock.Any()).Times(0) + mock.EXPECT().GetSaleOrderName(gomock.Any()).Times(0) + + _, err := (&SaleOrderReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: record.NewFakeRecorder(3), + SaleOrderStorage: mock, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: subject.Name, + }, + }) + + require.NoError(t, err) + require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject)) +} + +func Test_SaleOrderReconciler_Create_Error(t *testing.T) { + ctx := context.Background() + mctrl := gomock.NewController(t) + mock := mock_saleorder.NewMockSaleOrderStorage(mctrl) + + subject := organizationv1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "subject", + }, + Spec: organizationv1.OrganizationSpec{ + BillingEntityRef: "be-0000", + }, + } + c := prepareTest(t, &subject) + + gomock.InOrder( + mock.EXPECT().CreateSaleOrder(gomock.Any()).Return("", errors.New("An unanticipated fault has come to pass.")), + ) + + _, err := (&SaleOrderReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: record.NewFakeRecorder(3), + SaleOrderStorage: mock, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: subject.Name, + }, + }) + + require.Error(t, err) + require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject)) + cond := apimeta.FindStatusCondition(subject.Status.Conditions, organizationv1.ConditionSaleOrderCreated) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, organizationv1.ConditionReasonCreateFailed, cond.Reason) + require.Equal(t, "An unanticipated fault has come to pass.", cond.Message) +} + +func Test_SaleOrderReconciler_UpdateName_Error(t *testing.T) { + ctx := context.Background() + mctrl := gomock.NewController(t) + mock := mock_saleorder.NewMockSaleOrderStorage(mctrl) + + subject := organizationv1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "subject", + }, + Spec: organizationv1.OrganizationSpec{ + BillingEntityRef: "be-0000", + }, + Status: organizationv1.OrganizationStatus{ + SaleOrderID: "123", + }, + } + c := prepareTest(t, &subject) + + gomock.InOrder( + mock.EXPECT().GetSaleOrderName(gomock.Any()).Return("", errors.New("An unanticipated fault has come to pass.")), + ) + + _, err := (&SaleOrderReconciler{ + Client: c, + Scheme: c.Scheme(), + Recorder: record.NewFakeRecorder(3), + SaleOrderStorage: mock, + }).Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: subject.Name, + }, + }) + + require.Error(t, err) + require.NoError(t, c.Get(ctx, types.NamespacedName{Name: subject.Name}, &subject)) + cond := apimeta.FindStatusCondition(subject.Status.Conditions, organizationv1.ConditionSaleOrderNameUpdated) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, organizationv1.ConditionReasonGetNameFailed, cond.Reason) + require.Equal(t, "An unanticipated fault has come to pass.", cond.Message) +} diff --git a/controllers/saleorder/mock_saleorder/mock.go b/controllers/saleorder/mock_saleorder/mock.go new file mode 100644 index 00000000..0474b282 --- /dev/null +++ b/controllers/saleorder/mock_saleorder/mock.go @@ -0,0 +1,122 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: controllers/saleorder/saleorder_storage.go +// +// Generated by this command: +// +// mockgen -source=controllers/saleorder/saleorder_storage.go +// +// Package mock_saleorder is a generated GoMock package. +package mock_saleorder + +import ( + reflect "reflect" + + v1 "github.com/appuio/control-api/apis/organization/v1" + odoo "github.com/appuio/go-odoo" + gomock "go.uber.org/mock/gomock" +) + +// MockSaleOrderStorage is a mock of SaleOrderStorage interface. +type MockSaleOrderStorage struct { + ctrl *gomock.Controller + recorder *MockSaleOrderStorageMockRecorder +} + +// MockSaleOrderStorageMockRecorder is the mock recorder for MockSaleOrderStorage. +type MockSaleOrderStorageMockRecorder struct { + mock *MockSaleOrderStorage +} + +// NewMockSaleOrderStorage creates a new mock instance. +func NewMockSaleOrderStorage(ctrl *gomock.Controller) *MockSaleOrderStorage { + mock := &MockSaleOrderStorage{ctrl: ctrl} + mock.recorder = &MockSaleOrderStorageMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSaleOrderStorage) EXPECT() *MockSaleOrderStorageMockRecorder { + return m.recorder +} + +// CreateSaleOrder mocks base method. +func (m *MockSaleOrderStorage) CreateSaleOrder(arg0 v1.Organization) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateSaleOrder", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateSaleOrder indicates an expected call of CreateSaleOrder. +func (mr *MockSaleOrderStorageMockRecorder) CreateSaleOrder(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSaleOrder", reflect.TypeOf((*MockSaleOrderStorage)(nil).CreateSaleOrder), arg0) +} + +// GetSaleOrderName mocks base method. +func (m *MockSaleOrderStorage) GetSaleOrderName(arg0 v1.Organization) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSaleOrderName", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSaleOrderName indicates an expected call of GetSaleOrderName. +func (mr *MockSaleOrderStorageMockRecorder) GetSaleOrderName(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSaleOrderName", reflect.TypeOf((*MockSaleOrderStorage)(nil).GetSaleOrderName), arg0) +} + +// 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 +} + +// CreateSaleOrder mocks base method. +func (m *MockOdoo16Client) CreateSaleOrder(arg0 *odoo.SaleOrder) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateSaleOrder", arg0) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateSaleOrder indicates an expected call of CreateSaleOrder. +func (mr *MockOdoo16ClientMockRecorder) CreateSaleOrder(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSaleOrder", reflect.TypeOf((*MockOdoo16Client)(nil).CreateSaleOrder), arg0) +} + +// Read mocks base method. +func (m *MockOdoo16Client) Read(arg0 string, arg1 []int64, arg2 *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) +} diff --git a/controllers/saleorder/saleorder_storage.go b/controllers/saleorder/saleorder_storage.go new file mode 100644 index 00000000..806fbe1e --- /dev/null +++ b/controllers/saleorder/saleorder_storage.go @@ -0,0 +1,129 @@ +package saleorder + +import ( + "fmt" + "strconv" + "strings" + + organizationv1 "github.com/appuio/control-api/apis/organization/v1" + odooclient "github.com/appuio/go-odoo" +) + +type Odoo16Credentials = odooclient.ClientConfig + +type Odoo16Options struct { + SaleOrderClientReferencePrefix string + SaleOrderInternalNote string +} + +const defaultSaleOrderState = "sale" + +type SaleOrderStorage interface { + CreateSaleOrder(organizationv1.Organization) (string, error) + GetSaleOrderName(organizationv1.Organization) (string, error) +} + +type Odoo16Client interface { + Read(string, []int64, *odooclient.Options, interface{}) error + CreateSaleOrder(*odooclient.SaleOrder) (int64, error) +} + +type Odoo16SaleOrderStorage struct { + client Odoo16Client + options *Odoo16Options +} + +func NewOdoo16Storage(credentials *Odoo16Credentials, options *Odoo16Options) (SaleOrderStorage, error) { + client, err := odooclient.NewClient(credentials) + return &Odoo16SaleOrderStorage{ + client: client, + options: options, + }, err +} + +func NewOdoo16StorageFromClient(client Odoo16Client, options *Odoo16Options) SaleOrderStorage { + return &Odoo16SaleOrderStorage{ + client: client, + options: options, + } +} + +func (s *Odoo16SaleOrderStorage) CreateSaleOrder(org organizationv1.Organization) (string, error) { + beID, err := k8sIDToOdooID(org.Spec.BillingEntityRef) + if err != nil { + return "", err + } + + fetchPartnerFieldOpts := odooclient.NewOptions().FetchFields( + "id", + "parent_id", + ) + + beRecords := []odooclient.ResPartner{} + err = s.client.Read(odooclient.ResPartnerModel, []int64{int64(beID)}, fetchPartnerFieldOpts, &beRecords) + if err != nil { + return "", fmt.Errorf("fetching accounting contact by ID: %w", err) + } + + if len(beRecords) <= 0 { + return "", fmt.Errorf("no results when fetching accounting contact by ID") + } + beRecord := beRecords[0] + + if beRecord.ParentId == nil { + return "", fmt.Errorf("accounting contact %d has no parent", beRecord.Id.Get()) + } + + var clientRef string + if org.Spec.DisplayName != "" { + clientRef = fmt.Sprintf("%s (%s)", s.options.SaleOrderClientReferencePrefix, org.Spec.DisplayName) + } else { + clientRef = fmt.Sprintf("%s (%s)", s.options.SaleOrderClientReferencePrefix, org.ObjectMeta.Name) + } + + newSaleOrder := odooclient.SaleOrder{ + PartnerInvoiceId: odooclient.NewMany2One(beRecord.Id.Get(), ""), + PartnerId: odooclient.NewMany2One(beRecord.ParentId.ID, ""), + State: odooclient.NewSelection(defaultSaleOrderState), + ClientOrderRef: odooclient.NewString(clientRef), + InternalNote: odooclient.NewString(s.options.SaleOrderInternalNote), + } + + soID, err := s.client.CreateSaleOrder(&newSaleOrder) + if err != nil { + return "", fmt.Errorf("creating new sale order: %w", err) + } + + return fmt.Sprint(soID), nil +} + +func (s *Odoo16SaleOrderStorage) GetSaleOrderName(org organizationv1.Organization) (string, error) { + fetchOrderFieldOpts := odooclient.NewOptions().FetchFields( + "id", + "name", + ) + id, err := strconv.Atoi(org.Status.SaleOrderID) + if err != nil { + return "", err + } + soRecords := []odooclient.SaleOrder{} + err = s.client.Read(odooclient.SaleOrderModel, []int64{int64(id)}, fetchOrderFieldOpts, &soRecords) + if err != nil { + return "", fmt.Errorf("fetching sale order by ID: %w", err) + } + + if len(soRecords) <= 0 { + return "", fmt.Errorf("no results when fetching sale order by ID") + } + + return soRecords[0].Name.Get(), 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:]) +} diff --git a/controllers/saleorder/saleorder_storage_test.go b/controllers/saleorder/saleorder_storage_test.go new file mode 100644 index 00000000..d1b5390c --- /dev/null +++ b/controllers/saleorder/saleorder_storage_test.go @@ -0,0 +1,140 @@ +package saleorder_test + +import ( + "fmt" + "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" + + organizationv1 "github.com/appuio/control-api/apis/organization/v1" + "github.com/appuio/control-api/controllers/saleorder" + "github.com/appuio/control-api/controllers/saleorder/mock_saleorder" + odooclient "github.com/appuio/go-odoo" +) + +func TestCreate(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(), []int64{int64(123)}, 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().CreateSaleOrder(gomock.Any()).Return(int64(149), nil), + ) + + soid, err := subject.CreateSaleOrder(organizationv1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myorg", + }, + Spec: organizationv1.OrganizationSpec{ + BillingEntityRef: "be-123", + }, + }) + require.NoError(t, err) + assert.Equal(t, "149", soid) +} + +func TestGet(t *testing.T) { + ctrl, mock, subject := createStorage(t) + defer ctrl.Finish() + + gomock.InOrder( + mock.EXPECT().Read(gomock.Any(), []int64{int64(149)}, gomock.Any(), gomock.Any()).SetArg(3, []odooclient.SaleOrder{{ + Id: odooclient.NewInt(456), + Name: odooclient.NewString("SO149"), + }}).Return(nil), + ) + + soid, err := subject.GetSaleOrderName(organizationv1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myorg", + }, + Spec: organizationv1.OrganizationSpec{ + BillingEntityRef: "be-123", + }, + Status: organizationv1.OrganizationStatus{ + SaleOrderID: "149", + }, + }) + require.NoError(t, err) + assert.Equal(t, "SO149", soid) +} + +func TestCreateAttributes(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(), []int64{int64(123)}, 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().CreateSaleOrder(SaleOrderMatcher{ + PartnerId: int64(123), + PartnerInvoiceId: int64(456), + State: "sale", + ClientOrderRef: "client-ref (myorg)", + InternalNote: "internal-note", + }).Return(int64(149), nil), + ) + + soid, err := subject.CreateSaleOrder(organizationv1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myorg", + }, + Spec: organizationv1.OrganizationSpec{ + BillingEntityRef: "be-123", + }, + }) + require.NoError(t, err) + assert.Equal(t, "149", soid) +} + +type SaleOrderMatcher struct { + PartnerId int64 + PartnerInvoiceId int64 + State string + ClientOrderRef string + InternalNote string +} + +func (s SaleOrderMatcher) Matches(x interface{}) bool { + so := x.(*odooclient.SaleOrder) + return so.PartnerId.ID == s.PartnerId && so.PartnerInvoiceId.ID == s.PartnerInvoiceId && so.State.Get() == s.State && so.ClientOrderRef.Get() == s.ClientOrderRef && so.InternalNote.Get() == s.InternalNote +} +func (s SaleOrderMatcher) String() string { + return fmt.Sprintf("{PartnerId:%d PartnerInvoiceId:%d State:%s ClientOrderRef:%s InternalNote:%s}", s.PartnerId, s.PartnerInvoiceId, s.State, s.ClientOrderRef, s.InternalNote) +} + +func createStorage(t *testing.T) (*gomock.Controller, *mock_saleorder.MockOdoo16Client, saleorder.SaleOrderStorage) { + ctrl := gomock.NewController(t) + mock := mock_saleorder.NewMockOdoo16Client(ctrl) + + return ctrl, mock, saleorder.NewOdoo16StorageFromClient( + mock, + &saleorder.Odoo16Options{ + SaleOrderClientReferencePrefix: "client-ref", + SaleOrderInternalNote: "internal-note", + }, + ) +} diff --git a/go.mod b/go.mod index aec1f647..7e94d963 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.20 require ( github.com/Masterminds/sprig/v3 v3.2.3 - github.com/appuio/go-odoo v0.3.0 + github.com/appuio/go-odoo v0.4.0 github.com/go-logr/zapr v1.3.0 github.com/google/uuid v1.4.0 github.com/prometheus/client_golang v1.17.0 diff --git a/go.sum b/go.sum index 7fc9cb1a..98c76360 100644 --- a/go.sum +++ b/go.sum @@ -58,8 +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/appuio/go-odoo v0.4.0 h1:P1INin+2VnuzCjl5Q65p8DI4YdIw3PYLL9+vvS22iZE= +github.com/appuio/go-odoo v0.4.0/go.mod h1:pN7SdgIUWAS6hMW0L99FaofBG+5pSUA77vRcUT0mxjY= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= From 25af57948cb65571d7941edcd57f8f90f81f0f10 Mon Sep 17 00:00:00 2001 From: Aline Abler Date: Thu, 7 Dec 2023 10:56:11 +0100 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Sebastian Widmer --- controllers/saleorder/saleorder_storage.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controllers/saleorder/saleorder_storage.go b/controllers/saleorder/saleorder_storage.go index 806fbe1e..0fe179cc 100644 --- a/controllers/saleorder/saleorder_storage.go +++ b/controllers/saleorder/saleorder_storage.go @@ -104,7 +104,7 @@ func (s *Odoo16SaleOrderStorage) GetSaleOrderName(org organizationv1.Organizatio ) id, err := strconv.Atoi(org.Status.SaleOrderID) if err != nil { - return "", err + return "", fmt.Errorf("error parsing saleOrderID %q from organization status: %w", org.Status.SaleOrderID, err) } soRecords := []odooclient.SaleOrder{} err = s.client.Read(odooclient.SaleOrderModel, []int64{int64(id)}, fetchOrderFieldOpts, &soRecords) @@ -113,7 +113,7 @@ func (s *Odoo16SaleOrderStorage) GetSaleOrderName(org organizationv1.Organizatio } if len(soRecords) <= 0 { - return "", fmt.Errorf("no results when fetching sale order by ID") + return "", fmt.Errorf("no results when fetching sale orders with ID %q", id) } return soRecords[0].Name.Get(), nil