pkg/controller/license/trial/trial_controller.go (216 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.
package trial
import (
"bytes"
"context"
"fmt"
"reflect"
"time"
pkgerrors "github.com/pkg/errors"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
"sigs.k8s.io/controller-runtime/pkg/source"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common"
licensing "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler"
"github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/tracing"
"github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s"
ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log"
)
const (
name = "trial-controller"
EULAValidationMsg = `Please set the annotation elastic.co/eula to "accepted" to accept the EULA`
trialOnlyOnceMsg = "trial can be started only once"
)
var (
userFriendlyMsgs = map[licensing.LicenseStatus]string{
licensing.LicenseStatusInvalid: "trial license signature invalid",
licensing.LicenseStatusExpired: "trial license expired",
}
)
// ReconcileTrials reconciles Enterprise trial licenses.
type ReconcileTrials struct {
k8s.Client
operator.Parameters
recorder record.EventRecorder
// iteration is the number of times this controller has run its Reconcile method.
iteration uint64
trialState licensing.TrialState
}
// Reconcile watches a trial status secret. If it finds a trial license it checks whether a trial has been started.
// If not it starts the trial period if the user has expressed intent to do so.
// If a trial is already running it validates the trial license.
func (r *ReconcileTrials) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) {
ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, name, "secret_name", request)
defer common.LogReconciliationRun(ulog.FromContext(ctx))()
defer tracing.EndContextTransaction(ctx)
log := ulog.FromContext(ctx)
secret, license, err := licensing.TrialLicense(r, request.NamespacedName)
if err != nil && errors.IsNotFound(err) {
log.Info("Trial license secret has been deleted by user, but trial had been started previously.")
return reconcile.Result{}, nil
}
if err != nil {
return reconcile.Result{}, pkgerrors.Wrap(err, "while fetching trial license")
}
if !license.IsECKManagedTrial() {
// ignore externally generated licenses
return reconcile.Result{}, nil
}
validationMsg := validateEULA(secret)
if validationMsg != "" {
return reconcile.Result{}, r.invalidOperation(ctx, secret, validationMsg)
}
// 1. reconcile trial status secret
if err := r.reconcileTrialStatus(ctx, request.NamespacedName, license); err != nil {
return reconcile.Result{}, pkgerrors.Wrap(err, "while reconciling trial status")
}
// 2. reconcile the trial license itself
trialLicensePopulated := license.IsMissingFields() == nil
licenseStatus := r.validateLicense(ctx, license)
switch {
case !trialLicensePopulated && r.trialState.IsTrialStarted():
// user wants to start a trial for the second time
return reconcile.Result{}, r.invalidOperation(ctx, secret, trialOnlyOnceMsg)
case !trialLicensePopulated && !r.trialState.IsTrialStarted():
// user wants to init a trial for the first time
return reconcile.Result{}, r.initTrialLicense(ctx, secret, license)
case trialLicensePopulated && !validLicense(licenseStatus):
// existing license is invalid (expired or tampered with)
return reconcile.Result{}, r.invalidOperation(ctx, secret, userFriendlyMsgs[licenseStatus])
case trialLicensePopulated && validLicense(licenseStatus) && !r.trialState.IsTrialStarted():
// valid license, let's consider the trial started and complete the activation
return reconcile.Result{}, r.completeTrialActivation(ctx, request.NamespacedName)
case trialLicensePopulated && validLicense(licenseStatus) && r.trialState.IsTrialStarted():
// all good nothing to do
}
return reconcile.Result{}, err
}
func (r *ReconcileTrials) reconcileTrialStatus(ctx context.Context, licenseName types.NamespacedName, license licensing.EnterpriseLicense) error {
var trialStatus corev1.Secret
err := r.Get(ctx, types.NamespacedName{Namespace: r.OperatorNamespace, Name: licensing.TrialStatusSecretKey}, &trialStatus)
if errors.IsNotFound(err) {
if r.trialState.IsEmpty() {
// we have no state in memory nor in the status secret: start the activation process
if err := r.startTrialActivation(); err != nil {
return err
}
}
// we have state in memory but the status secret is missing: recreate it
trialStatus, err = licensing.ExpectedTrialStatus(r.OperatorNamespace, licenseName, r.trialState)
if err != nil {
return fmt.Errorf("while creating expected trial status %w", err)
}
return r.Create(ctx, &trialStatus)
}
if err != nil {
return fmt.Errorf("while fetching trial status %w", err)
}
// the status secret is there but we don't have anything in memory: recover the state
if r.trialState.IsEmpty() {
recoveredState, err := recoverState(license, trialStatus)
if err != nil {
return err
}
r.trialState = recoveredState
}
// if trial status exists, but we need to update it because:
// - has been tampered with
// - we need to complete the trial activation because if failed on a previous attempt
// - we just regenerated the state after a crash
expected, err := licensing.ExpectedTrialStatus(r.OperatorNamespace, licenseName, r.trialState)
if err != nil {
return err
}
if reflect.DeepEqual(expected.Data, trialStatus.Data) {
return nil
}
trialStatus.Data = expected.Data
return r.Update(ctx, &trialStatus)
}
func recoverState(license licensing.EnterpriseLicense, trialStatus corev1.Secret) (licensing.TrialState, error) {
// allow new trial state only if we don't have license that looks like it has been populated previously
allowNewState := license.IsMissingFields() != nil
// create new keys if the operator failed just before the trial was started
trialActivationInProgress := bytes.Equal(trialStatus.Data[licensing.TrialActivationKey], []byte("true"))
if trialActivationInProgress && allowNewState {
return licensing.NewTrialState()
}
// otherwise just recover the public key
return licensing.NewTrialStateFromStatus(trialStatus)
}
func (r *ReconcileTrials) startTrialActivation() error {
state, err := licensing.NewTrialState()
if err != nil {
return err
}
r.trialState = state
return nil
}
func (r *ReconcileTrials) completeTrialActivation(ctx context.Context, license types.NamespacedName) error {
if r.trialState.CompleteTrialActivation() {
expectedStatus, err := licensing.ExpectedTrialStatus(r.OperatorNamespace, license, r.trialState)
if err != nil {
return err
}
_, err = reconciler.ReconcileSecret(ctx, r, expectedStatus, nil)
return err
}
return nil
}
func (r *ReconcileTrials) initTrialLicense(ctx context.Context, secret corev1.Secret, license licensing.EnterpriseLicense) error {
if err := r.trialState.InitTrialLicense(ctx, &license); err != nil {
return err
}
return licensing.UpdateEnterpriseLicense(ctx, r, secret, license)
}
func (r *ReconcileTrials) invalidOperation(ctx context.Context, secret corev1.Secret, msg string) error {
setValidationMsg(ctx, &secret, msg)
return r.Update(ctx, &secret)
}
func validLicense(status licensing.LicenseStatus) bool {
return status == licensing.LicenseStatusValid
}
func (r *ReconcileTrials) validateLicense(ctx context.Context, license licensing.EnterpriseLicense) licensing.LicenseStatus {
return r.trialState.LicenseVerifier().Valid(ctx, license, time.Now())
}
func validateEULA(trialSecret corev1.Secret) string {
if licensing.IsEnterpriseTrial(trialSecret) &&
trialSecret.Annotations[licensing.EULAAnnotation] != licensing.EULAAcceptedValue {
return EULAValidationMsg
}
return ""
}
func setValidationMsg(ctx context.Context, secret *corev1.Secret, violation string) {
if secret.Annotations == nil {
secret.Annotations = map[string]string{}
}
ulog.FromContext(ctx).Info("trial license invalid", "reason", violation)
secret.Annotations[licensing.LicenseInvalidAnnotation] = violation
}
func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileTrials {
return &ReconcileTrials{
Client: mgr.GetClient(),
Parameters: params,
recorder: mgr.GetEventRecorderFor(name),
}
}
func addWatches(mgr manager.Manager, c controller.Controller) error {
// Watch the trial status secret and the enterprise trial licenses as well
return c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{},
handler.TypedEnqueueRequestsFromMapFunc[*corev1.Secret](func(ctx context.Context, secret *corev1.Secret) []reconcile.Request {
if licensing.IsEnterpriseTrial(*secret) {
return []reconcile.Request{
{
NamespacedName: types.NamespacedName{
Namespace: secret.GetNamespace(),
Name: secret.GetName(),
},
},
}
}
if secret.GetName() != licensing.TrialStatusSecretKey {
return nil
}
return []reconcile.Request{
{
NamespacedName: types.NamespacedName{
Namespace: secret.Annotations[licensing.TrialLicenseSecretNamespace],
Name: secret.Annotations[licensing.TrialLicenseSecretName],
},
},
}
}),
))
}
// Add creates a new Trial Controller and adds it to the Manager with default RBAC. The Manager will set fields on the Controller
// and Start it when the Manager is Started.
func Add(mgr manager.Manager, params operator.Parameters) error {
r := newReconciler(mgr, params)
c, err := common.NewController(mgr, name, r, params)
if err != nil {
return err
}
return addWatches(mgr, c)
}
var _ reconcile.Reconciler = &ReconcileTrials{}