diff --git a/cmd/main.go b/cmd/main.go index c266b75..c613c4d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -27,10 +27,14 @@ import ( fluxcdv1 "github.com/controlplaneio-fluxcd/flux-operator/api/v1" "github.com/controlplaneio-fluxcd/flux-operator/internal/controller" + "github.com/controlplaneio-fluxcd/flux-operator/internal/entitlement" // +kubebuilder:scaffold:imports ) -const controllerName = "flux-controller" +const ( + controllerName = "flux-operator" + defaultNamespace = "flux-system" +) var ( scheme = runtime.NewScheme() @@ -70,6 +74,12 @@ func main() { logger.SetLogger(logger.NewLogger(logOptions)) + runtimeNamespace := os.Getenv("RUNTIME_NAMESPACE") + if runtimeNamespace == "" { + runtimeNamespace = defaultNamespace + setupLog.Info("RUNTIME_NAMESPACE env var not set, defaulting to " + defaultNamespace) + } + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: metricsserver.Options{ @@ -95,7 +105,7 @@ func main() { // Only the FluxInstance with the name 'flux' can be reconciled. Field: fields.SelectorFromSet(fields.Set{ "metadata.name": "flux", - "metadata.namespace": os.Getenv("RUNTIME_NAMESPACE"), + "metadata.namespace": runtimeNamespace, }), }, }, @@ -106,6 +116,28 @@ func main() { os.Exit(1) } + entitlementClient, err := entitlement.NewClient() + if err != nil { + setupLog.Error(err, "unable to create entitlement client") + os.Exit(1) + } + + if err = (&controller.EntitlementReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + StatusPoller: polling.NewStatusPoller(mgr.GetClient(), mgr.GetRESTMapper(), polling.Options{}), + StatusManager: controllerName, + EventRecorder: mgr.GetEventRecorderFor(controllerName), + WatchNamespace: runtimeNamespace, + EntitlementClient: entitlementClient, + }).SetupWithManager(mgr, + controller.EntitlementReconcilerOptions{ + RateLimiter: runtimeCtrl.GetRateLimiter(rateLimiterOptions), + }); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Entitlement") + os.Exit(1) + } + if err = (&controller.FluxInstanceReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/go.mod b/go.mod index d1e3b51..a4f5e8e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.22.0 require ( github.com/Masterminds/semver/v3 v3.2.1 + github.com/aws/aws-sdk-go-v2 v1.28.0 + github.com/aws/aws-sdk-go-v2/config v1.27.19 + github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.11 github.com/fluxcd/cli-utils v0.36.0-flux.7 github.com/fluxcd/pkg/apis/kustomize v1.5.0 github.com/fluxcd/pkg/apis/meta v1.5.0 @@ -11,6 +14,7 @@ require ( github.com/fluxcd/pkg/runtime v0.47.1 github.com/fluxcd/pkg/ssa v0.39.1 github.com/fluxcd/pkg/tar v0.7.0 + github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/go-containerregistry v0.19.1 github.com/onsi/ginkgo/v2 v2.19.0 github.com/onsi/gomega v1.33.1 @@ -29,6 +33,17 @@ require ( require ( github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.19 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.20.12 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.28.13 // indirect + github.com/aws/smithy-go v1.20.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index a1caedd..274ccf5 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,34 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0 github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aws/aws-sdk-go-v2 v1.28.0 h1:ne6ftNhY0lUvlazMUQF15FF6NH80wKmPRFG7g2q6TCw= +github.com/aws/aws-sdk-go-v2 v1.28.0/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2/config v1.27.19 h1:+DBS8gJP6VsxYkZ6UEV0/VsRM2rYpbQCYsosW9RRmeQ= +github.com/aws/aws-sdk-go-v2/config v1.27.19/go.mod h1:KzZcioJWzy9oV+oS5CobYXlDtU9+eW7bPG1g7gizTW4= +github.com/aws/aws-sdk-go-v2/credentials v1.17.19 h1:R18G7nBBGLby51CFEqUBFF2IVl7LUdCtYj6iosUwh/0= +github.com/aws/aws-sdk-go-v2/credentials v1.17.19/go.mod h1:xr9kUMnaLTB866HItT6pg58JgiBP77fSQLBwIa//zk8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.6 h1:vVOuhRyslJ6T/HteG71ZWCTas1q2w6f0NKsNbkXHs/A= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.6/go.mod h1:jimWaqLiT0sJGLh51dKCLLtExRYPtMU7MpxuCgtbkxg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.10 h1:LZIUb8sQG2cb89QaVFtMSnER10gyKkqU1k3hP3g9das= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.10/go.mod h1:BRIqay//vnIOCZjoXWSLffL2uzbtxEmnSlfbvVh7Z/4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.10 h1:HY7CXLA0GiQUo3WYxOP7WYkLcwvRX4cLPf5joUcrQGk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.10/go.mod h1:kfRBSxRa+I+VyON7el3wLZdrO91oxUxEwdAaWgFqN90= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.12 h1:kO2J7WMroF/OTHN9WTcUtMjPhJ7ZoNxx0dwv6UCXQgY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.12/go.mod h1:mrNxrjYvXaSjZe5fkKaWgDnOQ6BExLn/7Ru9OpRsMPY= +github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.11 h1:hv7JaWwhc6+mwixzbayWMDiMFX591d86VqIRUJUkLqE= +github.com/aws/aws-sdk-go-v2/service/marketplacemetering v1.21.11/go.mod h1:Z/w6+UQdM5RgBx1111Y2juR+32q16x6s2q+dHywuXWU= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.12 h1:FsYii6U+2k8ynYBo+pywlCBY9HNAFRh+iICRHbn+Qyw= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.12/go.mod h1:j9Rps+Lcs2A0tYypWsNBeJOjgsIYUf1Styppo9Es0Wo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6 h1:lEE+xEcq3lh9bk362tgErP1+n689q5ERdmTwmF1XT3M= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.6/go.mod h1:2tR0x1DCL5IgnVZ1NQNFDNg5/XL/kiQgWI5l7I/N5Js= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.13 h1:TSzmuUeruVJ4XWYp3bYzKCXue70ECpJWmbP3UfEvhYY= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.13/go.mod h1:FppRtFjBA9mSWTj2cIAWCP66+bbBPMuPpBfWRXC5Yi0= +github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= +github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -87,6 +115,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= diff --git a/internal/controller/entitlement_controller.go b/internal/controller/entitlement_controller.go new file mode 100644 index 0000000..578ae31 --- /dev/null +++ b/internal/controller/entitlement_controller.go @@ -0,0 +1,218 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package controller + +import ( + "context" + "fmt" + "time" + + "github.com/fluxcd/cli-utils/pkg/kstatus/polling" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kuberecorder "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/ratelimiter" + + "github.com/controlplaneio-fluxcd/flux-operator/internal/entitlement" +) + +// EntitlementReconciler reconciles entitlements. +type EntitlementReconciler struct { + client.Client + kuberecorder.EventRecorder + + EntitlementClient entitlement.Client + Scheme *runtime.Scheme + StatusPoller *polling.StatusPoller + StatusManager string + WatchNamespace string +} + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (r *EntitlementReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) { + log := ctrl.LoggerFrom(ctx) + + namespace := &corev1.Namespace{} + if err := r.Get(ctx, req.NamespacedName, namespace); err != nil { + return ctrl.Result{}, err + } + + secret, err := r.GetEntitlementSecret(ctx) + if err != nil { + return ctrl.Result{}, err + } + + log.Info(fmt.Sprintf("Reconciling entitlement %s/%s", namespace.Name, secret.Name), + entitlement.VendorKey, string(secret.Data[entitlement.VendorKey])) + + var token string + id := string(namespace.UID) + + // Get the token from the secret if it exists. + if t, found := secret.Data[entitlement.TokenKey]; found { + token = string(t) + } + + // Register the usage if the token is missing and update the secret. + if token == "" { + token, err = r.EntitlementClient.RegisterUsage(ctx, id) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to register usage for vendor %s: %w", + r.EntitlementClient.GetVendor(), err) + } + + if err := r.UpdateEntitlementSecret(ctx, token); err != nil { + return ctrl.Result{}, err + } + + log.Info("Entitlement registered", "vendor", r.EntitlementClient.GetVendor()) + + // Requeue to verify the token. + return ctrl.Result{Requeue: true}, nil + } + + // Verify the token and delete the secret if it is invalid. + valid, err := r.EntitlementClient.Verify(token, id) + if !valid { + if err := r.DeleteEntitlementSecret(ctx, secret); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, fmt.Errorf("failed to verify entitlement: %w", err) + } + + log.Info("Entitlement verified", "vendor", r.EntitlementClient.GetVendor()) + return ctrl.Result{RequeueAfter: 30 * time.Minute}, nil +} + +// EntitlementReconcilerOptions contains options for the reconciler. +type EntitlementReconcilerOptions struct { + RateLimiter ratelimiter.RateLimiter +} + +// SetupWithManager sets up the controller with the Manager and initializes the +// entitlement secret in the watch namespace. +func (r *EntitlementReconciler) SetupWithManager(mgr ctrl.Manager, opts EntitlementReconcilerOptions) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if _, err := r.InitEntitlementSecret(ctx); err != nil { + return err + } + + ps, err := predicate.LabelSelectorPredicate(metav1.LabelSelector{ + MatchLabels: map[string]string{ + "kubernetes.io/metadata.name": r.WatchNamespace, + }, + }) + if err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr).For( + &corev1.Namespace{}, + builder.WithPredicates(ps)). + WithEventFilter(predicate.AnnotationChangedPredicate{}). + WithOptions(controller.Options{RateLimiter: opts.RateLimiter}). + Complete(r) +} + +// InitEntitlementSecret creates the entitlement secret if it doesn't exist +// and sets the entitlement vendor if it's missing or different. +func (r *EntitlementReconciler) InitEntitlementSecret(ctx context.Context) (*corev1.Secret, error) { + secretName := fmt.Sprintf("%s-entitlement", r.StatusManager) + secret := &corev1.Secret{} + err := r.Client.Get(ctx, client.ObjectKey{ + Namespace: r.WatchNamespace, + Name: secretName, + }, secret) + if err != nil { + if apierrors.IsNotFound(err) { + newSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: r.WatchNamespace, + Labels: map[string]string{ + "app.kubernetes.io/name": r.StatusManager, + "app.kubernetes.io/component": "entitlement", + "app.kubernetes.io/managed-by": r.StatusManager, + }, + }, + Data: map[string][]byte{ + entitlement.VendorKey: []byte(r.EntitlementClient.GetVendor()), + }, + } + errNew := r.Client.Create(ctx, newSecret) + if errNew != nil { + return nil, fmt.Errorf("failed to create %s: %w", secretName, errNew) + } + return newSecret, nil + } else { + return nil, fmt.Errorf("failed to init %s: %w", secretName, err) + } + } + + definedVendor, found := secret.Data[entitlement.VendorKey] + if !found || string(definedVendor) != r.EntitlementClient.GetVendor() { + secret.Data = make(map[string][]byte) + secret.Data[entitlement.VendorKey] = []byte(r.EntitlementClient.GetVendor()) + if err := r.Client.Update(ctx, secret); err != nil { + return nil, fmt.Errorf("failed to set vendor in %s: %w", secretName, err) + } + } + + return secret, nil +} + +// GetEntitlementSecret returns the entitlement secret. +// if the secret doesn't exist, it gets initialized. +func (r *EntitlementReconciler) GetEntitlementSecret(ctx context.Context) (*corev1.Secret, error) { + log := ctrl.LoggerFrom(ctx) + secretName := fmt.Sprintf("%s-entitlement", r.StatusManager) + secret := &corev1.Secret{} + err := r.Client.Get(ctx, client.ObjectKey{ + Namespace: r.WatchNamespace, + Name: secretName, + }, secret) + if err != nil { + if apierrors.IsNotFound(err) { + log.Error(err, fmt.Sprintf("Entitlement not found, initializing %s/%s", r.WatchNamespace, secretName)) + return r.InitEntitlementSecret(ctx) + } + return nil, fmt.Errorf("failed to get %s: %w", secretName, err) + } + + return secret, nil +} + +// UpdateEntitlementSecret updates the token in the entitlement secret. +func (r *EntitlementReconciler) UpdateEntitlementSecret(ctx context.Context, token string) error { + secret, err := r.GetEntitlementSecret(ctx) + if err != nil { + return err + } + + secret.Data[entitlement.TokenKey] = []byte(token) + if err := r.Client.Update(ctx, secret); err != nil { + return fmt.Errorf("failed to update %s: %w", secret.Name, err) + } + + return nil +} + +// DeleteEntitlementSecret deletes the entitlement secret. +func (r *EntitlementReconciler) DeleteEntitlementSecret(ctx context.Context, secret *corev1.Secret) error { + if err := r.Client.Delete(ctx, secret); err != nil { + return fmt.Errorf("failed to delete %s: %w", secret.Name, err) + } + + return nil +} diff --git a/internal/controller/entitlement_controller_test.go b/internal/controller/entitlement_controller_test.go new file mode 100644 index 0000000..4a54922 --- /dev/null +++ b/internal/controller/entitlement_controller_test.go @@ -0,0 +1,83 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package controller + +import ( + "context" + "testing" + "time" + + "github.com/fluxcd/cli-utils/pkg/kstatus/polling" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/controlplaneio-fluxcd/flux-operator/internal/entitlement" +) + +func TestEntitlementReconciler_ReconcileDefaultVendor(t *testing.T) { + g := NewWithT(t) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ns, err := testEnv.CreateNamespace(ctx, "test") + g.Expect(err).ToNot(HaveOccurred()) + + reconciler := getEntitlementReconciler(ns.Name) + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ns)}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result.Requeue).To(BeTrue()) + + secret, err := reconciler.GetEntitlementSecret(ctx) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secret.Data).To(HaveKeyWithValue(entitlement.VendorKey, []byte(entitlement.DefaultVendor))) + g.Expect(secret.Data).To(HaveKey(entitlement.TokenKey)) + + result, err = reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(ns)}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result.RequeueAfter).To(Equal(30 * time.Minute)) + + dc := &entitlement.DefaultClient{Vendor: entitlement.DefaultVendor} + token, err := dc.RegisterUsage(ctx, string(ns.UID)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secret.Data).To(HaveKeyWithValue(entitlement.TokenKey, []byte(token))) +} + +func TestEntitlementReconciler_InitEntitlementSecret(t *testing.T) { + g := NewWithT(t) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + ns, err := testEnv.CreateNamespace(ctx, "test") + g.Expect(err).ToNot(HaveOccurred()) + + reconciler := getEntitlementReconciler(ns.Name) + + rs, err := reconciler.InitEntitlementSecret(ctx) + g.Expect(err).ToNot(HaveOccurred()) + + secret := &corev1.Secret{} + err = reconciler.Get(ctx, client.ObjectKey{Namespace: ns.Name, Name: rs.Name}, secret) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(secret.Data).To(HaveKeyWithValue(entitlement.VendorKey, []byte(entitlement.DefaultVendor))) +} + +func getEntitlementReconciler(ns string) *EntitlementReconciler { + ec, err := entitlement.NewClient() + if err != nil { + panic(err) + } + return &EntitlementReconciler{ + Client: testClient, + EventRecorder: testEnv.GetEventRecorderFor(controllerName), + Scheme: NewTestScheme(), + StatusPoller: polling.NewStatusPoller(testClient, testEnv.GetRESTMapper(), polling.Options{}), + StatusManager: controllerName, + WatchNamespace: ns, + EntitlementClient: ec, + } +} diff --git a/internal/controller/fluxinstance_controller.go b/internal/controller/fluxinstance_controller.go index 89f1a11..607ae83 100644 --- a/internal/controller/fluxinstance_controller.go +++ b/internal/controller/fluxinstance_controller.go @@ -378,6 +378,10 @@ func (r *FluxInstanceReconciler) apply(ctx context.Context, Name: "kubectl", OperationType: metav1.ManagedFieldsOperationUpdate, }, + { + Name: "flux-controller", + OperationType: metav1.ManagedFieldsOperationApply, + }, }, } diff --git a/internal/entitlement/aws.go b/internal/entitlement/aws.go new file mode 100644 index 0000000..41ebd74 --- /dev/null +++ b/internal/entitlement/aws.go @@ -0,0 +1,107 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package entitlement + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/marketplacemetering" + "github.com/golang-jwt/jwt/v4" +) + +const ( + // awsMarketplaceProductCode is the AWS Marketplace + // product code of ControlPlane Enterprise for Flux CD. + awsMarketplaceProductCode = "272knt6mdmtctbck10givwm1h" + // awsMarketplacePublicKeyVersion is the AWS Marketplace + // public key version of ControlPlane Enterprise for Flux CD. + awsMarketplacePublicKeyVersion = 1 + // awsMarketplacePublicKey is the AWS Marketplace + // public key of ControlPlane Enterprise for Flux CD. + awsMarketplacePublicKey = `-----BEGIN PUBLIC KEY----- +MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAnFKsHLJY5om6uta6LfG/ +3tfnLO08ZRHeZrromJSEyG+/zdmVJ7s8thk4JQzNxt8fUzgMvKdPuWl17vYay18P +pCiypg6EbihzIO3VOQCGp1bOSnHrvUlyoyhDuNnG8213DFbl4+MmVwEkI4F25sUq +56uwmZyHc77ZsjvvFs0pcJ0VQ+DhG0LSjUMmtukeh2VQ29yuQCiKCML4JOkwIRuP +cmetwvbgn1ViFWwSrhE2i/cNjBzXdd1kz23rmLM4rx4LctsUSAIP3I5YRy4wLUiG +q+M3YOAcfxQP3t5cjN7rRfyE/bUz+BvipKEPCoDMKmbbNRyX9WYPeIrRsW4HJWGj +GO0dKDZJJwhMa5TM5zVwepfLeGakxprL7j+0EGFWvf8M0+qZ9OGgEFNVwDVu2BoH +d1prsH3fI7CTCztrIBgCwtqBhQ5wxzlrnBrDy4WA+CwLFhW77Tghw1E62Vcpj/v5 +vLhgbv3IpMBX2ugEWgeB2i0yWYCpheC8lkbgI90SY+GFAgMBAAE= +-----END PUBLIC KEY-----` +) + +// AmazonClient is an entitlement client for the +// ControlPlane Enterprise for Flux CD AWS Marketplace product. +// https://aws.amazon.com/marketplace/pp/prodview-ndm54wno7tayg +type AmazonClient struct { + Vendor string + mc *marketplacemetering.Client +} + +// NewAmazonClient creates a new AmazonClient using the default +// AWS configuration and the current region. +func NewAmazonClient(vendor string) (*AmazonClient, error) { + cfg, err := config.LoadDefaultConfig(context.Background(), config.WithEC2IMDSRegion()) + if err != nil { + return nil, fmt.Errorf("failed to load AWS configuration: %w", err) + } + + return &AmazonClient{ + Vendor: vendor, + mc: marketplacemetering.NewFromConfig(cfg), + }, nil +} + +// RegisterUsage registers the usage with AWS Marketplace +// metering service and returns a JWT token. +func (c *AmazonClient) RegisterUsage(ctx context.Context, id string) (string, error) { + input := &marketplacemetering.RegisterUsageInput{ + ProductCode: aws.String(awsMarketplaceProductCode), + PublicKeyVersion: aws.Int32(awsMarketplacePublicKeyVersion), + Nonce: aws.String(id), + } + + output, err := c.mc.RegisterUsage(ctx, input) + if err != nil { + return "", fmt.Errorf("failed to register usage with AWS Marketplace: %w", err) + } + + return aws.ToString(output.Signature), nil +} + +// Verify verifies the JWT token is signed with the AWS Marketplace public key +// and checks the product code, nonce and public key version claims. +func (c *AmazonClient) Verify(token, id string) (bool, error) { + t, err := jwt.ParseWithClaims(token, jwt.MapClaims{}, func(_ *jwt.Token) (any, error) { + return jwt.ParseRSAPublicKeyFromPEM([]byte(awsMarketplacePublicKey)) + }) + if err != nil { + return false, fmt.Errorf("AWS Marketplace invalid token: %w", err) + } + + if !t.Valid { + return false, fmt.Errorf("AWS Marketplace invalid token") + } + + claims := t.Claims.(jwt.MapClaims) + switch { + case claims["productCode"] != awsMarketplaceProductCode: + return false, fmt.Errorf("AWS Marketplace product code mismatch: %s", claims["productCode"]) + case claims["nonce"] != id: + return false, fmt.Errorf("AWS Marketplace nonce mismatch: %s", claims["nonce"]) + case claims["publicKeyVersion"] != float64(awsMarketplacePublicKeyVersion): + return false, fmt.Errorf("AWS Marketplace public key version mismatch: %f", claims["publicKeyVersion"]) + } + + return true, nil +} + +// GetVendor returns the vendor name. +func (c *AmazonClient) GetVendor() string { + return c.Vendor +} diff --git a/internal/entitlement/client.go b/internal/entitlement/client.go new file mode 100644 index 0000000..f53d528 --- /dev/null +++ b/internal/entitlement/client.go @@ -0,0 +1,62 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package entitlement + +import ( + "context" + "fmt" + "os" + "strings" +) + +const ( + // VendorKey is the key in the entitlement secret + // that holds the vendor name. + VendorKey = "vendor" + + // TokenKey is the key in the entitlement secret + // that holds the token. + TokenKey = "token" + + // DefaultVendor is the default vendor name. + DefaultVendor = "controlplane" + + // MarketplaceTypeEnvKey is the environment variable key + // that holds the marketplace type. + MarketplaceTypeEnvKey = "MARKETPLACE_TYPE" +) + +// Client is the interface for entitlement clients +// that can register usage and verify tokens. +type Client interface { + // RegisterUsage registers the usage with the entitlement service + // and returns a signed JWT token. + RegisterUsage(ctx context.Context, id string) (string, error) + + // Verify verifies that the token is signed by the + // entitlement service and matches the usage id. + Verify(token, id string) (bool, error) + + // GetVendor returns the vendor name. + GetVendor() string +} + +// NewClient returns a new entitlement client based on the +// marketplace type environment variable. +func NewClient() (Client, error) { + vendor := DefaultVendor + marketplace, found := os.LookupEnv(MarketplaceTypeEnvKey) + if found && marketplace != "" && marketplace != DefaultVendor { + vendor = fmt.Sprintf("%s-%s", DefaultVendor, strings.ToLower(marketplace)) + } + + switch vendor { + case DefaultVendor: + return &DefaultClient{Vendor: vendor}, nil + case "controlplane-aws": + return NewAmazonClient(vendor) + } + + return nil, fmt.Errorf("unsupported vendor %s", vendor) +} diff --git a/internal/entitlement/default.go b/internal/entitlement/default.go new file mode 100644 index 0000000..5a8be4e --- /dev/null +++ b/internal/entitlement/default.go @@ -0,0 +1,34 @@ +// Copyright 2024 Stefan Prodan. +// SPDX-License-Identifier: AGPL-3.0 + +package entitlement + +import ( + "context" + "fmt" + + "github.com/opencontainers/go-digest" +) + +// DefaultClient is an offline entitlement client. +// This client uses a SHA256 digest to generate and verify tokens. +type DefaultClient struct { + Vendor string +} + +// RegisterUsage registers the usage with the default entitlement client. +func (c *DefaultClient) RegisterUsage(ctx context.Context, id string) (string, error) { + d := digest.FromString(fmt.Sprintf("%s-%s", c.Vendor, id)) + return d.Encoded(), nil +} + +// Verify verifies the token matches the SHA256 digest of the vendor id. +func (c *DefaultClient) Verify(token, id string) (bool, error) { + d := digest.FromString(fmt.Sprintf("%s-%s", c.Vendor, id)) + return token == d.Encoded(), nil +} + +// GetVendor returns the vendor name. +func (c *DefaultClient) GetVendor() string { + return c.Vendor +}