Skip to content

Commit

Permalink
Support service account creation for pods with "credentials-operator.…
Browse files Browse the repository at this point in the history
…otterize.com/service-account-name" annotation (#82)
  • Loading branch information
omris94 authored Sep 28, 2023
1 parent 7332fff commit d73dad8
Show file tree
Hide file tree
Showing 12 changed files with 335 additions and 13 deletions.
11 changes: 11 additions & 0 deletions src/operator/config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ rules:
- patch
- update
- watch
- apiGroups:
- ""
resources:
- serviceaccounts
verbs:
- create
- get
- list
- patch
- update
- watch
- apiGroups:
- apps
resources:
Expand Down
3 changes: 3 additions & 0 deletions src/operator/controllers/metadata/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const (
TLSSecretNameAnnotation = "credentials-operator.otterize.com/tls-secret-name"
TLSSecretNameAnnotationDeprecated = "spire-integration.otterize.com/tls-secret-name"

// ServiceAccountNameAnnotation is the name of the k8s service account that the operator will create
ServiceAccountNameAnnotation = "credentials-operator.otterize.com/service-account-name"

// DNSNamesAnnotation is a comma-separated list of additional dns names to be registered as part of the
// SPIRE-server entry and encoded into the certificate data
DNSNamesAnnotation = "credentials-operator.otterize.com/dns-names"
Expand Down
5 changes: 4 additions & 1 deletion src/operator/controllers/metadata/labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ const (
// See documentation for intents.otterize.com/service-name annotation for information re service name resolution.
RegisteredServiceNameLabel = "credentials-operator.otterize.com/registered-service-name"

// SecretTypeLabel is used to label secrets generated by the spire-integration-operator with their secret type.
// SecretTypeLabel is used to label secrets generated by the credentials-operator with their secret type.
// This is used to detect which secrets are managed by this operator.
SecretTypeLabel = "credentials-operator.otterize.com/secret-type"

// OtterizeServiceAccountLabel is used to label service accounts generated by the credentials-operator
OtterizeServiceAccountLabel = "credentials-operator.otterize.com/service-account"
)
14 changes: 13 additions & 1 deletion src/operator/controllers/pod_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ type SecretsManager interface {
RefreshTLSSecrets(ctx context.Context) error
}

type ServiceAccountEnsurer interface {
EnsureServiceAccount(ctx context.Context, pod *corev1.Pod) error
}

// PodReconciler reconciles a Pod object
type PodReconciler struct {
client.Client
Expand All @@ -56,16 +60,18 @@ type PodReconciler struct {
serviceIdResolver *serviceidresolver.Resolver
eventRecorder record.EventRecorder
registerOnlyPodsWithSecretAnnotation bool
serviceAccountEnsurer ServiceAccountEnsurer
}

// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=core,resources=secrets,verbs=create;get;list;update;patch;watch
// +kubebuilder:rbac:groups=apps,resources=replicasets;daemonsets;statefulsets;deployments,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups="",resources=events,verbs=get;update;patch;list;watch;create
// +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;update;patch;list;watch;create

func NewPodReconciler(client client.Client, scheme *runtime.Scheme, workloadRegistry WorkloadRegistry,
secretsManager SecretsManager, serviceIdResolver *serviceidresolver.Resolver, eventRecorder record.EventRecorder,
registerOnlyPodsWithSecretAnnotation bool) *PodReconciler {
ServiceAccountEnsurer ServiceAccountEnsurer, registerOnlyPodsWithSecretAnnotation bool) *PodReconciler {
return &PodReconciler{
Client: client,
scheme: scheme,
Expand All @@ -74,6 +80,7 @@ func NewPodReconciler(client client.Client, scheme *runtime.Scheme, workloadRegi
serviceIdResolver: serviceIdResolver,
eventRecorder: eventRecorder,
registerOnlyPodsWithSecretAnnotation: registerOnlyPodsWithSecretAnnotation,
serviceAccountEnsurer: ServiceAccountEnsurer,
}
}

Expand Down Expand Up @@ -177,6 +184,11 @@ func (r *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R
return ctrl.Result{}, nil
}

err := r.serviceAccountEnsurer.EnsureServiceAccount(ctx, pod)
if err != nil {
return ctrl.Result{}, err
}

if !r.shouldRegisterEntryForPod(pod) {
return ctrl.Result{}, nil
}
Expand Down
22 changes: 14 additions & 8 deletions src/operator/controllers/pod_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/otterize/credentials-operator/src/controllers/secrets/types"
"github.com/otterize/credentials-operator/src/mocks/controller-runtime/client"
mock_secrets "github.com/otterize/credentials-operator/src/mocks/controllers/secrets"
mockserviceaccounts "github.com/otterize/credentials-operator/src/mocks/controllers/serviceaccounts"
mock_entries "github.com/otterize/credentials-operator/src/mocks/entries"
mock_record "github.com/otterize/credentials-operator/src/mocks/eventrecorder"
mock_spireclient "github.com/otterize/credentials-operator/src/mocks/spireclient"
Expand All @@ -29,12 +30,13 @@ import (

type PodControllerSuite struct {
suite.Suite
controller *gomock.Controller
client *mock_client.MockClient
spireClient *mock_spireclient.MockServerClient
entriesRegistry *mock_entries.MockWorkloadRegistry
secretsManager *mock_secrets.MockSecretsManager
podReconciler *PodReconciler
controller *gomock.Controller
client *mock_client.MockClient
spireClient *mock_spireclient.MockServerClient
entriesRegistry *mock_entries.MockWorkloadRegistry
secretsManager *mock_secrets.MockSecretsManager
podReconciler *PodReconciler
ServiceAccountEnsurer *mockserviceaccounts.MockServiceAccountEnsurer
}

type PodControllerSuiteWithoutEventRecorder struct {
Expand All @@ -51,12 +53,14 @@ func (s *PodControllerSuiteWithoutEventRecorder) SetupTest() {
eventRecorder := mock_record.NewMockEventRecorder(s.controller)
eventRecorder.EXPECT().Event(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
eventRecorder.EXPECT().Eventf(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
s.ServiceAccountEnsurer = mockserviceaccounts.NewMockServiceAccountEnsurer(s.controller)
s.ServiceAccountEnsurer.EXPECT().EnsureServiceAccount(gomock.Any(), gomock.Any()).AnyTimes()

scheme := runtime.NewScheme()
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
s.client.EXPECT().Scheme().Return(scheme).AnyTimes()
s.podReconciler = NewPodReconciler(s.client, nil, s.entriesRegistry, s.secretsManager,
serviceIdResolver, eventRecorder, false)
serviceIdResolver, eventRecorder, s.ServiceAccountEnsurer, false)
}

type ObjectNameMatcher struct {
Expand Down Expand Up @@ -256,8 +260,10 @@ func (s *PodControllerSuiteWithEventRecorder) SetupTest() {
scheme := runtime.NewScheme()
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
s.client.EXPECT().Scheme().Return(scheme).AnyTimes()
s.ServiceAccountEnsurer = mockserviceaccounts.NewMockServiceAccountEnsurer(s.controller)
s.ServiceAccountEnsurer.EXPECT().EnsureServiceAccount(gomock.Any(), gomock.Any()).AnyTimes()
s.podReconciler = NewPodReconciler(s.client, nil, s.entriesRegistry, s.secretsManager,
serviceIdResolver, s.eventRecorder, false)
serviceIdResolver, s.eventRecorder, s.ServiceAccountEnsurer, false)
}

func (s *PodControllerSuiteWithEventRecorder) TestController_Reconcile_DeprecatedAnnotations() {
Expand Down
82 changes: 82 additions & 0 deletions src/operator/controllers/serviceaccount/ensurer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package serviceaccount

import (
"context"
"fmt"
"github.com/otterize/credentials-operator/src/controllers/metadata"
"github.com/sirupsen/logrus"
v1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
)

const (
ReasonCreateServiceAccount = "CreateServiceAccount"
ReasonCreatingServiceAccountFailed = "CreatingServiceAccountFailed"
ReasonCreateServiceAccountSkipped = "CreatingServiceAccountSkipped"
)

type Ensurer struct {
client.Client
recorder record.EventRecorder
}

func NewServiceAccountEnsurer(client client.Client, eventRecorder record.EventRecorder) *Ensurer {
return &Ensurer{Client: client, recorder: eventRecorder}
}

func isServiceAccountNameValid(name string) bool {
return len(validation.IsDNS1123Subdomain(name)) == 0
}

func (e *Ensurer) EnsureServiceAccount(ctx context.Context, pod *v1.Pod) error {
if pod.Annotations == nil {
return nil
}
serviceAccountName, annotationExists := pod.Annotations[metadata.ServiceAccountNameAnnotation]
if !annotationExists {
logrus.Debugf("pod %s does'nt have service account annotation, skipping ensure service account", pod)
return nil
}

if !isServiceAccountNameValid(serviceAccountName) {
err := fmt.Errorf("service account name %s is invalid according to 'RFC 1123 subdomain'. skipping service account ensure for pod %s", serviceAccountName, pod)
logrus.Warningf(err.Error())
e.recorder.Eventf(pod, v1.EventTypeWarning, ReasonCreatingServiceAccountFailed, err.Error())
return err
}

serviceAccount := v1.ServiceAccount{}
err := e.Client.Get(ctx, types.NamespacedName{Namespace: pod.Namespace, Name: serviceAccountName}, &serviceAccount)
if err != nil && !apierrors.IsNotFound(err) {
logrus.Errorf("failed get service accounts: %s", err.Error())
e.recorder.Eventf(pod, v1.EventTypeWarning, ReasonCreatingServiceAccountFailed, "Failed creating service account: %s", err.Error())
return err
} else if err == nil {
logrus.Debugf("service account %s already exists, skipping service account creation", serviceAccountName)
e.recorder.Eventf(pod, v1.EventTypeNormal, ReasonCreateServiceAccountSkipped, "service account %s already exists, skipping service account creation", serviceAccountName)
return nil
}

logrus.Infof("creating service account named %s for pod %s", serviceAccountName, pod)
if err := e.createServiceAccount(ctx, serviceAccountName, pod); err != nil {
logrus.Errorf("failed creating service account for pod %s: %s", pod, err.Error())
e.recorder.Eventf(pod, v1.EventTypeWarning, ReasonCreatingServiceAccountFailed, "Failed creating service account: %s", err.Error())
return err
}
e.recorder.Eventf(pod, v1.EventTypeNormal, ReasonCreateServiceAccount, "Successfully created service account: %s", serviceAccountName)
logrus.Infof("successfuly created service account named %s for pod %s", serviceAccountName, pod)

return nil
}

func (e *Ensurer) createServiceAccount(ctx context.Context, serviceAccountName string, pod *v1.Pod) error {
serviceAccount := v1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{Name: serviceAccountName, Namespace: pod.Namespace, Labels: map[string]string{metadata.OtterizeServiceAccountLabel: serviceAccountName}},
}
return e.Client.Create(ctx, &serviceAccount)
}
144 changes: 144 additions & 0 deletions src/operator/controllers/serviceaccount/ensurer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package serviceaccount

import (
"context"
"errors"
"fmt"
"github.com/otterize/credentials-operator/src/controllers/metadata"
mock_client "github.com/otterize/credentials-operator/src/mocks/controller-runtime/client"
mock_record "github.com/otterize/credentials-operator/src/mocks/eventrecorder"
"github.com/stretchr/testify/suite"
"go.uber.org/mock/gomock"
v1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"net/http"
"testing"
)

type serviceAccountMatcher struct {
Name string
Namespace string
}

func (m *serviceAccountMatcher) String() string {
return fmt.Sprintf("expected Name: %s Namespace: %s", m.Name, m.Namespace)
}

func (m *serviceAccountMatcher) Matches(x interface{}) bool {
sa := x.(*v1.ServiceAccount)
return sa.Name == m.Name && sa.Namespace == m.Namespace
}

type PodServiceAccountEnsurerSuite struct {
suite.Suite
controller *gomock.Controller
client *mock_client.MockClient
ServiceAccountEnsurer *Ensurer
mockEventRecorder *mock_record.MockEventRecorder
}

func (s *PodServiceAccountEnsurerSuite) SetupTest() {
s.controller = gomock.NewController(s.T())
s.client = mock_client.NewMockClient(s.controller)

scheme := runtime.NewScheme()
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
s.client.EXPECT().Scheme().Return(scheme).AnyTimes()
s.mockEventRecorder = mock_record.NewMockEventRecorder(s.controller)
s.ServiceAccountEnsurer = NewServiceAccountEnsurer(s.client, s.mockEventRecorder)
}

func (s *PodServiceAccountEnsurerSuite) TestCreate() {
serviceAccountName := "cool.name"
annotations := map[string]string{metadata.ServiceAccountNameAnnotation: serviceAccountName}
namespace := "namespace"
s.client.EXPECT().Get(gomock.Any(), gomock.Eq(types.NamespacedName{Name: serviceAccountName, Namespace: namespace}), gomock.AssignableToTypeOf(&v1.ServiceAccount{})).
Return(
&k8serrors.StatusError{
ErrStatus: metav1.Status{Status: metav1.StatusFailure, Code: http.StatusNotFound, Reason: metav1.StatusReasonNotFound},
})

s.client.EXPECT().Create(gomock.Any(), &serviceAccountMatcher{Name: serviceAccountName, Namespace: namespace})
s.mockEventRecorder.EXPECT().Eventf(gomock.Any(), gomock.Eq(v1.EventTypeNormal), gomock.Eq(ReasonCreateServiceAccount), gomock.Any(), gomock.Any())
err := s.ServiceAccountEnsurer.EnsureServiceAccount(context.Background(), &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "Pod", Namespace: namespace, Annotations: annotations}})
s.Require().NoError(err)

}

func (s *PodServiceAccountEnsurerSuite) TestDoesntCreateWhenFound() {
serviceAccountName := "cool.name"
annotations := map[string]string{metadata.ServiceAccountNameAnnotation: serviceAccountName}
namespace := "namespace"
s.client.EXPECT().Get(gomock.Any(), gomock.Eq(types.NamespacedName{Name: serviceAccountName, Namespace: namespace}), gomock.AssignableToTypeOf(&v1.ServiceAccount{})).
Return(nil)

s.mockEventRecorder.EXPECT().Eventf(gomock.Any(), gomock.Eq(v1.EventTypeNormal), gomock.Eq(ReasonCreateServiceAccountSkipped), gomock.Any(), gomock.Any())

err := s.ServiceAccountEnsurer.EnsureServiceAccount(context.Background(), &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "Pod", Namespace: namespace, Annotations: annotations}})
s.Require().NoError(err)

}

func (s *PodServiceAccountEnsurerSuite) TestDoesntCreateWhenInvalidName() {
// Name with caps RFC 1123 subdomain
annotations := map[string]string{metadata.ServiceAccountNameAnnotation: "NameWithCapitalLetters"}
s.mockEventRecorder.EXPECT().Eventf(gomock.Any(), gomock.Eq(v1.EventTypeWarning), gomock.Eq(ReasonCreatingServiceAccountFailed), gomock.Any())
err := s.ServiceAccountEnsurer.EnsureServiceAccount(context.Background(), &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "Pod", Namespace: "namespace", Annotations: annotations}})
s.Require().Error(err)

// Very long Name (>253)
annotations = map[string]string{metadata.ServiceAccountNameAnnotation: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
s.mockEventRecorder.EXPECT().Eventf(gomock.Any(), gomock.Eq(v1.EventTypeWarning), gomock.Eq(ReasonCreatingServiceAccountFailed), gomock.Any())
err = s.ServiceAccountEnsurer.EnsureServiceAccount(context.Background(), &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "Pod", Namespace: "namespace", Annotations: annotations}})
s.Require().Error(err)

// Name with /
annotations = map[string]string{metadata.ServiceAccountNameAnnotation: "name/asd"}
s.mockEventRecorder.EXPECT().Eventf(gomock.Any(), gomock.Eq(v1.EventTypeWarning), gomock.Eq(ReasonCreatingServiceAccountFailed), gomock.Any())
err = s.ServiceAccountEnsurer.EnsureServiceAccount(context.Background(), &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "Pod", Namespace: "namespace", Annotations: annotations}})
s.Require().Error(err)

}

func (s *PodServiceAccountEnsurerSuite) TestDoesntCreateWhenNoAnnotation() {
err := s.ServiceAccountEnsurer.EnsureServiceAccount(context.Background(), &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "Pod", Namespace: "namespace"}})
s.Require().NoError(err)
}

func (s *PodServiceAccountEnsurerSuite) TestEventOnErrorListing() {
serviceAccountName := "cool.name"
annotations := map[string]string{metadata.ServiceAccountNameAnnotation: serviceAccountName}
namespace := "namespace"
s.client.EXPECT().Get(gomock.Any(), gomock.Eq(types.NamespacedName{Name: serviceAccountName, Namespace: namespace}), gomock.AssignableToTypeOf(&v1.ServiceAccount{})).
Return(errors.New("unexpected error"))

s.mockEventRecorder.EXPECT().Eventf(gomock.Any(), gomock.Eq(v1.EventTypeWarning), gomock.Eq(ReasonCreatingServiceAccountFailed), gomock.Any(), gomock.Any())
err := s.ServiceAccountEnsurer.EnsureServiceAccount(context.Background(), &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "Pod", Namespace: namespace, Annotations: annotations}})
s.Require().Error(err)

}

func (s *PodServiceAccountEnsurerSuite) TestEventOnErrorCreating() {
serviceAccountName := "cool.name"
annotations := map[string]string{metadata.ServiceAccountNameAnnotation: serviceAccountName}
namespace := "namespace"
s.client.EXPECT().Get(gomock.Any(), gomock.Eq(types.NamespacedName{Name: serviceAccountName, Namespace: namespace}), gomock.AssignableToTypeOf(&v1.ServiceAccount{})).
Return(
&k8serrors.StatusError{
ErrStatus: metav1.Status{Status: metav1.StatusFailure, Code: http.StatusNotFound, Reason: metav1.StatusReasonNotFound},
})

s.client.EXPECT().Create(gomock.Any(), &serviceAccountMatcher{Name: serviceAccountName, Namespace: namespace}).Return(errors.New("unexpected error"))
s.mockEventRecorder.EXPECT().Eventf(gomock.Any(), gomock.Eq(v1.EventTypeWarning), gomock.Eq(ReasonCreatingServiceAccountFailed), gomock.Any(), gomock.Any())
err := s.ServiceAccountEnsurer.EnsureServiceAccount(context.Background(), &v1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "Pod", Namespace: namespace, Annotations: annotations}})
s.Require().Error(err)
}

func TestPodServiceAccountEnsurerSuite(t *testing.T) {
suite.Run(t, new(PodServiceAccountEnsurerSuite))
}
1 change: 1 addition & 0 deletions src/operator/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ package main
//go:generate go run go.uber.org/mock/mockgen@v0.2.0 -package mock_entries -destination=mocks/entries/mock.go github.com/otterize/credentials-operator/src/controllers WorkloadRegistry
//go:generate go run go.uber.org/mock/mockgen@v0.2.0 -destination=mocks/spireclient/svids/mock.go github.com/otterize/credentials-operator/src/controllers/spireclient/svids Store
//go:generate go run go.uber.org/mock/mockgen@v0.2.0 -package mock_secrets -destination=mocks/controllers/secrets/mock.go github.com/otterize/credentials-operator/src/controllers SecretsManager
//go:generate go run go.uber.org/mock/mockgen@v0.2.0 -package mockserviceaccounts -destination=mocks/controllers/serviceaccounts/mock.go github.com/otterize/credentials-operator/src/controllers ServiceAccountEnsurer
//go:generate go run go.uber.org/mock/mockgen@v0.2.0 -destination=mocks/eventrecorder/mock.go k8s.io/client-go/tools/record EventRecorder
//go:generate go run go.uber.org/mock/mockgen@v0.2.0 -destination=mocks/serviceidresolver/mock.go github.com/otterize/credentials-operator/src/controllers/secrets/types ServiceIdResolver
Loading

0 comments on commit d73dad8

Please sign in to comment.