diff --git a/apis/organization/v1/organization_types.go b/apis/organization/v1/organization_types.go index a84c348d..37e34b9a 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] + } + conditions := []metav1.Condition{} + err := json.Unmarshal([]byte(statusConditionsString), &conditions) + if err != nil { + conditions = []metav1.Condition{} } 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,20 @@ func (o *Organization) ToNamespace() *corev1.Namespace { if ns.Annotations == nil { ns.Annotations = map[string]string{} } + statusBytes, err := json.Marshal(o.Status.Conditions) + var statusString string + if err != nil { + statusString = "" + } + 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 + ns.Annotations[SaleOrderIdKey] = o.Status.SaleOrderID + ns.Annotations[SaleOrderNameKey] = o.Status.SaleOrderName + 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..e4a20b42 100644 --- a/controller.go +++ b/controller.go @@ -3,6 +3,7 @@ package main import ( "context" "flag" + "fmt" "os" "text/template" "time" @@ -28,6 +29,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 +68,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 +105,13 @@ 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.") + 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,9 @@ func ControllerCommand() *cobra.Command { *redeemedInvitationTTL, *invEmailBaseRetryDelay, invMailSender, + *saleOrderStorage, + *saleOrderClientReference, + oc, ctrl.Options{ Scheme: scheme, MetricsBindAddress: *metricsAddr, @@ -228,6 +241,9 @@ func setupManager( redeemedInvitationTTL time.Duration, invEmailBaseRetryDelay time.Duration, mailSender mailsenders.MailSender, + saleOrderStorage string, + saleOrderClientReference string, + odooCredentials saleorder.Odoo16Credentials, opt ctrl.Options, ) (ctrl.Manager, error) { mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), opt) @@ -320,6 +336,26 @@ func setupManager( return nil, err } + fmt.Printf("Sales order storage %s\n", saleOrderStorage) + if saleOrderStorage == "odoo16" { + fmt.Println("Now setting up sales order storage controller thingamabob") + storage, err := saleorder.NewOdoo16Storage(&odooCredentials, &saleorder.Odoo16Options{ + SalesOrderClientReference: saleOrderClientReference, + }) + 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/org_billingentity_name_cache_controller.go b/controllers/org_billingentity_name_cache_controller.go index 42364306..ea921e1e 100644 --- a/controllers/org_billingentity_name_cache_controller.go +++ b/controllers/org_billingentity_name_cache_controller.go @@ -57,9 +57,12 @@ func (r *OrgBillingEntityNameCacheController) Reconcile(ctx context.Context, req return ctrl.Result{}, err } - org.Status.BillingEntityName = be.Spec.Name - if err := r.Client.Status().Update(ctx, &org); err != nil { - return ctrl.Result{}, err + if org.Status.BillingEntityName != be.Spec.Name { + org.Status.BillingEntityName = be.Spec.Name + log.V(0).Info("other reconciler setting status", "status", org.Status) + if err := r.Client.Status().Update(ctx, &org); err != nil { + return ctrl.Result{}, err + } } // We can't watch BillingEntity resources, so we have to requeue diff --git a/controllers/sale_order_controller.go b/controllers/sale_order_controller.go new file mode 100644 index 00000000..28ff2e47 --- /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 Sales Order") + + 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/saleorder/saleorder_storage.go b/controllers/saleorder/saleorder_storage.go new file mode 100644 index 00000000..2900ef92 --- /dev/null +++ b/controllers/saleorder/saleorder_storage.go @@ -0,0 +1,108 @@ +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 { + SalesOrderClientReference string +} + +const defaultSaleOrderState = "sale" + +type SaleOrderStorage interface { + CreateSaleOrder(organizationv1.Organization) (string, error) + GetSaleOrderName(organizationv1.Organization) (string, error) +} + +type Odoo16SaleOrderStorage struct { + client *odooclient.Client + options *Odoo16Options +} + +func NewOdoo16Storage(credentials *Odoo16Credentials, options *Odoo16Options) (SaleOrderStorage, error) { + client, err := odooclient.NewClient(credentials) + return &Odoo16SaleOrderStorage{ + client: client, + options: options, + }, err +} + +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()) + } + + newSaleOrder := odooclient.SaleOrder{ + PartnerId: odooclient.NewMany2One(beRecord.Id.Get(), ""), + PartnerInvoiceId: odooclient.NewMany2One(beRecord.ParentId.ID, ""), + State: odooclient.NewSelection(defaultSaleOrderState), + ClientOrderRef: odooclient.NewString(s.options.SalesOrderClientReference), + } + + 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/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..11d7d731 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,8 @@ github.com/antlr/antlr4/runtime/Go/antlr v1.4.10 h1:yL7+Jz0jTC6yykIK/Wh74gnTJnrG 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=