Skip to content

Commit

Permalink
Merge pull request #182 from appuio/feat/sales-order-creation
Browse files Browse the repository at this point in the history
Reconcile organizations to create sales orders where needed
  • Loading branch information
HappyTetrahedron authored Dec 7, 2023
2 parents 8e0ae6d + 25af579 commit 5c79acf
Show file tree
Hide file tree
Showing 11 changed files with 859 additions and 5 deletions.
59 changes: 58 additions & 1 deletion apis/organization/v1/organization_types.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
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"
"k8s.io/apimachinery/pkg/runtime/schema"
"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 (
Expand All @@ -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
Expand All @@ -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(),
Expand All @@ -43,6 +71,9 @@ func NewOrganizationFromNS(ns *corev1.Namespace) *Organization {
},
Status: OrganizationStatus{
BillingEntityName: billingEntityName,
SaleOrderID: saleOrderId,
SaleOrderName: saleOrderName,
Conditions: conditions,
},
}
if org.Annotations != nil {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
10 changes: 9 additions & 1 deletion apis/organization/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions config/rbac/controller/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 37 additions & 0 deletions controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -182,6 +192,10 @@ func ControllerCommand() *cobra.Command {
*redeemedInvitationTTL,
*invEmailBaseRetryDelay,
invMailSender,
*saleOrderStorage,
*saleOrderClientReference,
*saleOrderInternalNote,
oc,
ctrl.Options{
Scheme: scheme,
MetricsBindAddress: *metricsAddr,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
102 changes: 102 additions & 0 deletions controllers/sale_order_controller.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 5c79acf

Please sign in to comment.