Skip to content

Commit 5c79acf

Browse files
Merge pull request #182 from appuio/feat/sales-order-creation
Reconcile organizations to create sales orders where needed
2 parents 8e0ae6d + 25af579 commit 5c79acf

File tree

11 files changed

+859
-5
lines changed

11 files changed

+859
-5
lines changed

apis/organization/v1/organization_types.go

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
package v1
22

33
import (
4+
"encoding/json"
5+
46
corev1 "k8s.io/api/core/v1"
57
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
68
runtime "k8s.io/apimachinery/pkg/runtime"
79
"k8s.io/apimachinery/pkg/runtime/schema"
810
"sigs.k8s.io/apiserver-runtime/pkg/builder/resource"
911
)
1012

13+
const (
14+
// SaleOrderCreated is set when the Sale Order has been created
15+
ConditionSaleOrderCreated = "SaleOrderCreated"
16+
17+
// SaleOrderNameUpdated is set when the Sale Order's name has been added to the Status
18+
ConditionSaleOrderNameUpdated = "SaleOrderNameUpdated"
19+
20+
ConditionReasonCreateFailed = "CreateFailed"
21+
22+
ConditionReasonGetNameFailed = "GetNameFailed"
23+
)
24+
1125
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create;delete;update
1226

1327
var (
@@ -21,6 +35,12 @@ var (
2135
BillingEntityRefKey = "organization.appuio.io/billing-entity-ref"
2236
// BillingEntityNameKey is the annotation key that stores the billing entity name
2337
BillingEntityNameKey = "status.organization.appuio.io/billing-entity-name"
38+
// SaleOrderIdKey is the annotation key that stores the sale order ID
39+
SaleOrderIdKey = "status.organization.appuio.io/sale-order-id"
40+
// SaleOrderNameKey is the annotation key that stores the sale order name
41+
SaleOrderNameKey = "status.organization.appuio.io/sale-order-name"
42+
// StatusConditionsKey is the annotation key that stores the serialized status conditions
43+
StatusConditionsKey = "status.organization.appuio.io/conditions"
2444
)
2545

2646
// NewOrganizationFromNS returns an Organization based on the given namespace
@@ -29,11 +49,19 @@ func NewOrganizationFromNS(ns *corev1.Namespace) *Organization {
2949
if ns == nil || ns.Labels == nil || ns.Labels[TypeKey] != OrgType {
3050
return nil
3151
}
32-
var displayName, billingEntityRef, billingEntityName string
52+
var displayName, billingEntityRef, billingEntityName, saleOrderId, saleOrderName, statusConditionsString string
3353
if ns.Annotations != nil {
3454
displayName = ns.Annotations[DisplayNameKey]
3555
billingEntityRef = ns.Annotations[BillingEntityRefKey]
3656
billingEntityName = ns.Annotations[BillingEntityNameKey]
57+
statusConditionsString = ns.Annotations[StatusConditionsKey]
58+
saleOrderId = ns.Annotations[SaleOrderIdKey]
59+
saleOrderName = ns.Annotations[SaleOrderNameKey]
60+
}
61+
var conditions []metav1.Condition
62+
err := json.Unmarshal([]byte(statusConditionsString), &conditions)
63+
if err != nil {
64+
conditions = nil
3765
}
3866
org := &Organization{
3967
ObjectMeta: *ns.ObjectMeta.DeepCopy(),
@@ -43,6 +71,9 @@ func NewOrganizationFromNS(ns *corev1.Namespace) *Organization {
4371
},
4472
Status: OrganizationStatus{
4573
BillingEntityName: billingEntityName,
74+
SaleOrderID: saleOrderId,
75+
SaleOrderName: saleOrderName,
76+
Conditions: conditions,
4677
},
4778
}
4879
if org.Annotations != nil {
@@ -79,6 +110,15 @@ type OrganizationSpec struct {
79110
type OrganizationStatus struct {
80111
// BillingEntityName is the name of the billing entity
81112
BillingEntityName string `json:"billingEntityName,omitempty"`
113+
114+
// SaleOrderID is the ID of the sale order
115+
SaleOrderID string `json:"saleOrderId,omitempty"`
116+
117+
// SaleOrderName is the name of the sale order
118+
SaleOrderName string `json:"saleOrderName,omitempty"`
119+
120+
// Conditions is a list of conditions for the invitation
121+
Conditions []metav1.Condition `json:"conditions,omitempty"`
82122
}
83123

84124
// Organization needs to implement the builder resource interface
@@ -149,10 +189,27 @@ func (o *Organization) ToNamespace() *corev1.Namespace {
149189
if ns.Annotations == nil {
150190
ns.Annotations = map[string]string{}
151191
}
192+
var statusString string
193+
if o.Status.Conditions != nil {
194+
statusBytes, err := json.Marshal(o.Status.Conditions)
195+
if err == nil {
196+
statusString = string(statusBytes)
197+
}
198+
}
199+
152200
ns.Labels[TypeKey] = OrgType
153201
ns.Annotations[DisplayNameKey] = o.Spec.DisplayName
154202
ns.Annotations[BillingEntityRefKey] = o.Spec.BillingEntityRef
155203
ns.Annotations[BillingEntityNameKey] = o.Status.BillingEntityName
204+
if o.Status.SaleOrderID != "" {
205+
ns.Annotations[SaleOrderIdKey] = o.Status.SaleOrderID
206+
}
207+
if o.Status.SaleOrderName != "" {
208+
ns.Annotations[SaleOrderNameKey] = o.Status.SaleOrderName
209+
}
210+
if statusString != "" {
211+
ns.Annotations[StatusConditionsKey] = statusString
212+
}
156213
return ns
157214
}
158215

apis/organization/v1/zz_generated.deepcopy.go

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/rbac/controller/role.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,19 @@ rules:
226226
- get
227227
- patch
228228
- update
229+
- apiGroups:
230+
- user.appuio.io
231+
resources:
232+
- organizations
233+
verbs:
234+
- get
235+
- list
236+
- watch
237+
- apiGroups:
238+
- user.appuio.io
239+
resources:
240+
- organizations/status
241+
verbs:
242+
- get
243+
- patch
244+
- update

controller.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
orgv1 "github.com/appuio/control-api/apis/organization/v1"
2929
userv1 "github.com/appuio/control-api/apis/user/v1"
3030
controlv1 "github.com/appuio/control-api/apis/v1"
31+
"github.com/appuio/control-api/controllers/saleorder"
3132
"github.com/appuio/control-api/mailsenders"
3233

3334
"github.com/appuio/control-api/controllers"
@@ -66,6 +67,7 @@ func ControllerCommand() *cobra.Command {
6667

6768
zapfs := flag.NewFlagSet("zap", flag.ExitOnError)
6869
opts := zap.Options{}
70+
oc := saleorder.Odoo16Credentials{}
6971
opts.BindFlags(zapfs)
7072
cmd.Flags().AddGoFlagSet(zapfs)
7173

@@ -102,6 +104,14 @@ func ControllerCommand() *cobra.Command {
102104
billingEntityEmailSubject := cmd.Flags().String("billingentity-email-subject", "An APPUiO Billing Entity has been updated", "Subject for billing entity modification update mails")
103105
billingEntityCronInterval := cmd.Flags().String("billingentity-email-cron-interval", "@every 1h", "Cron interval for how frequently billing entity update e-mails are sent")
104106

107+
saleOrderStorage := cmd.Flags().String("sale-order-storage", "none", "Type of sale order storage to use. Valid values are `none` and `odoo16`")
108+
saleOrderClientReference := cmd.Flags().String("sale-order-client-reference", "APPUiO Cloud", "Default client reference to add to newly created sales orders.")
109+
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.")
110+
cmd.Flags().StringVar(&oc.URL, "sale-order-odoo16-url", "http://localhost:8069", "URL of the Odoo instance to use for sale orders")
111+
cmd.Flags().StringVar(&oc.Database, "sale-order-odoo16-db", "odooDB", "Database of the Odoo instance to use for sale orders")
112+
cmd.Flags().StringVar(&oc.Admin, "sale-order-odoo16-account", "Admin", "Odoo Account name to use for sale orders")
113+
cmd.Flags().StringVar(&oc.Password, "sale-order-odoo16-password", "superSecret1238", "Odoo Account password to use for sale orders")
114+
105115
cmd.Run = func(*cobra.Command, []string) {
106116
scheme := runtime.NewScheme()
107117
setupLog := ctrl.Log.WithName("setup")
@@ -182,6 +192,10 @@ func ControllerCommand() *cobra.Command {
182192
*redeemedInvitationTTL,
183193
*invEmailBaseRetryDelay,
184194
invMailSender,
195+
*saleOrderStorage,
196+
*saleOrderClientReference,
197+
*saleOrderInternalNote,
198+
oc,
185199
ctrl.Options{
186200
Scheme: scheme,
187201
MetricsBindAddress: *metricsAddr,
@@ -228,6 +242,10 @@ func setupManager(
228242
redeemedInvitationTTL time.Duration,
229243
invEmailBaseRetryDelay time.Duration,
230244
mailSender mailsenders.MailSender,
245+
saleOrderStorage string,
246+
saleOrderClientReference string,
247+
saleOrderInternalNote string,
248+
odooCredentials saleorder.Odoo16Credentials,
231249
opt ctrl.Options,
232250
) (ctrl.Manager, error) {
233251
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), opt)
@@ -320,6 +338,25 @@ func setupManager(
320338
return nil, err
321339
}
322340

341+
if saleOrderStorage == "odoo16" {
342+
storage, err := saleorder.NewOdoo16Storage(&odooCredentials, &saleorder.Odoo16Options{
343+
SaleOrderClientReferencePrefix: saleOrderClientReference,
344+
SaleOrderInternalNote: saleOrderInternalNote,
345+
})
346+
if err != nil {
347+
return nil, err
348+
}
349+
saleorder := &controllers.SaleOrderReconciler{
350+
Client: mgr.GetClient(),
351+
Scheme: mgr.GetScheme(),
352+
Recorder: mgr.GetEventRecorderFor("sale-order-controller"),
353+
SaleOrderStorage: storage,
354+
}
355+
if err = saleorder.SetupWithManager(mgr); err != nil {
356+
return nil, err
357+
}
358+
}
359+
323360
metrics.Registry.MustRegister(invmail.GetMetrics())
324361

325362
mgr.GetWebhookServer().Register("/validate-appuio-io-v1-user", &webhook.Admission{

controllers/sale_order_controller.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package controllers
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"go.uber.org/multierr"
8+
apimeta "k8s.io/apimachinery/pkg/api/meta"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/runtime"
11+
"k8s.io/client-go/tools/record"
12+
ctrl "sigs.k8s.io/controller-runtime"
13+
"sigs.k8s.io/controller-runtime/pkg/client"
14+
"sigs.k8s.io/controller-runtime/pkg/log"
15+
16+
organizationv1 "github.com/appuio/control-api/apis/organization/v1"
17+
"github.com/appuio/control-api/controllers/saleorder"
18+
)
19+
20+
// SaleOrderReconciler reconciles invitations and adds a token to the status if required.
21+
type SaleOrderReconciler struct {
22+
client.Client
23+
24+
Recorder record.EventRecorder
25+
Scheme *runtime.Scheme
26+
27+
SaleOrderStorage saleorder.SaleOrderStorage
28+
}
29+
30+
//+kubebuilder:rbac:groups="rbac.appuio.io",resources=organizations,verbs=get;list;watch
31+
//+kubebuilder:rbac:groups="user.appuio.io",resources=organizations,verbs=get;list;watch
32+
//+kubebuilder:rbac:groups="rbac.appuio.io",resources=organizations/status,verbs=get;update;patch
33+
//+kubebuilder:rbac:groups="user.appuio.io",resources=organizations/status,verbs=get;update;patch
34+
35+
// Reconcile reacts to Organizations and creates Sale Orders if necessary
36+
func (r *SaleOrderReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
37+
log := log.FromContext(ctx)
38+
log.V(1).WithValues("request", req).Info("Reconciling")
39+
40+
org := organizationv1.Organization{}
41+
if err := r.Get(ctx, req.NamespacedName, &org); err != nil {
42+
return ctrl.Result{}, client.IgnoreNotFound(err)
43+
}
44+
45+
if org.Spec.BillingEntityRef == "" {
46+
return ctrl.Result{}, nil
47+
}
48+
49+
if org.Status.SaleOrderName != "" {
50+
return ctrl.Result{}, nil
51+
}
52+
53+
if org.Status.SaleOrderID != "" {
54+
// ID is present, but Name is not. Update name.
55+
soName, err := r.SaleOrderStorage.GetSaleOrderName(org)
56+
if err != nil {
57+
log.V(0).Error(err, "Error getting sale order name")
58+
apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{
59+
Type: organizationv1.ConditionSaleOrderNameUpdated,
60+
Status: metav1.ConditionFalse,
61+
Reason: organizationv1.ConditionReasonGetNameFailed,
62+
Message: err.Error(),
63+
})
64+
return ctrl.Result{}, multierr.Append(err, r.Client.Status().Update(ctx, &org))
65+
}
66+
apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{
67+
Type: organizationv1.ConditionSaleOrderNameUpdated,
68+
Status: metav1.ConditionTrue,
69+
})
70+
org.Status.SaleOrderName = soName
71+
return ctrl.Result{}, r.Client.Status().Update(ctx, &org)
72+
}
73+
74+
// Neither ID nor Name is present. Create new SO.
75+
soId, err := r.SaleOrderStorage.CreateSaleOrder(org)
76+
77+
if err != nil {
78+
log.V(0).Error(err, "Error creating sale order")
79+
apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{
80+
Type: organizationv1.ConditionSaleOrderCreated,
81+
Status: metav1.ConditionFalse,
82+
Reason: organizationv1.ConditionReasonCreateFailed,
83+
Message: err.Error(),
84+
})
85+
return ctrl.Result{}, multierr.Append(err, r.Client.Status().Update(ctx, &org))
86+
}
87+
88+
apimeta.SetStatusCondition(&org.Status.Conditions, metav1.Condition{
89+
Type: organizationv1.ConditionSaleOrderCreated,
90+
Status: metav1.ConditionTrue,
91+
})
92+
93+
org.Status.SaleOrderID = fmt.Sprint(soId)
94+
return ctrl.Result{}, r.Client.Status().Update(ctx, &org)
95+
}
96+
97+
// SetupWithManager sets up the controller with the Manager.
98+
func (r *SaleOrderReconciler) SetupWithManager(mgr ctrl.Manager) error {
99+
return ctrl.NewControllerManagedBy(mgr).
100+
For(&organizationv1.Organization{}).
101+
Complete(r)
102+
}

0 commit comments

Comments
 (0)