Skip to content

Commit a5aadbd

Browse files
authored
Do not use service accounts until Elasticsearch nodes have been upgraded (#5830) (#5831)
* Detect if all nodes are running with service accounts * Do not use service accounts until Elasticsearch supports them
1 parent 2545958 commit a5aadbd

File tree

13 files changed

+756
-35
lines changed

13 files changed

+756
-35
lines changed

pkg/apis/elasticsearch/v1/elasticsearch_types.go

+14
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package v1
77
import (
88
"strings"
99

10+
"github.com/blang/semver/v4"
1011
corev1 "k8s.io/api/core/v1"
1112
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1213

@@ -41,6 +42,19 @@ const (
4142
Kind = "Elasticsearch"
4243
)
4344

45+
// ServiceAccountMinVersion is the first version of Elasticsearch for which ECK supports service accounts.
46+
// It is however up to each association controller to ensure that a specific service account is available
47+
// in the current Elasticsearch version.
48+
var ServiceAccountMinVersion = semver.MustParse("7.17.0")
49+
50+
func AreServiceAccountsSupported(version string) (bool, error) {
51+
esVersion, err := semver.Parse(version)
52+
if err != nil {
53+
return false, err
54+
}
55+
return esVersion.GTE(ServiceAccountMinVersion), nil
56+
}
57+
4458
// +kubebuilder:object:root=true
4559

4660
// ElasticsearchList contains a list of Elasticsearch clusters

pkg/controller/association/reconciler.go

+17-3
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"github.com/elastic/cloud-on-k8s/pkg/controller/common/reconciler"
3333
"github.com/elastic/cloud-on-k8s/pkg/controller/common/tracing"
3434
"github.com/elastic/cloud-on-k8s/pkg/controller/common/watches"
35+
"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/hints"
3536
"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/user"
3637
"github.com/elastic/cloud-on-k8s/pkg/utils/k8s"
3738
"github.com/elastic/cloud-on-k8s/pkg/utils/maps"
@@ -330,10 +331,23 @@ func (r *Reconciler) reconcileAssociation(ctx context.Context, association commo
330331
if err != nil {
331332
return commonv1.AssociationPending, err
332333
}
333-
// Detect if we should use a service account. If it is the case create the related Secrets and update the association
334-
// configuration on the associated resource.
335-
assocLabels := r.AssociationResourceLabels(k8s.ExtractNamespacedName(association.Associated()), assocRef.NamespacedName())
334+
// Detect if we should use a service account.
335+
var esHints hints.OrchestrationsHints
336336
if len(serviceAccount) > 0 {
337+
// We must first ensure that the relevant orchestration hint is set on the Elasticsearch cluster.
338+
esHints, err = hints.NewFrom(es)
339+
if err != nil {
340+
return commonv1.AssociationPending, err
341+
}
342+
if !esHints.ServiceAccounts.IsSet() {
343+
r.log(k8s.ExtractNamespacedName(association)).Info("Waiting for Elasticsearch to report if service accounts are fully rolled out")
344+
return commonv1.AssociationPending, nil
345+
}
346+
}
347+
348+
// If it is the case create the related Secrets and update the association configuration on the associated resource.
349+
assocLabels := r.AssociationResourceLabels(k8s.ExtractNamespacedName(association.Associated()), assocRef.NamespacedName())
350+
if len(serviceAccount) > 0 && esHints.ServiceAccounts.IsTrue() {
337351
applicationSecretName := secretKey(association, r.ElasticsearchUserCreation.UserSecretSuffix)
338352
r.log(k8s.ExtractNamespacedName(association)).V(1).Info("Ensure service account exists", "sa", serviceAccount)
339353
err := ReconcileServiceAccounts(

pkg/controller/elasticsearch/client/client.go

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ type Client interface {
6060
DesiredNodesClient
6161
ShardLister
6262
LicenseClient
63+
SecurityClient
6364
// Close idle connections in the underlying http client.
6465
Close()
6566
// Equal returns true if other can be considered as the same client.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
package client
6+
7+
import (
8+
"context"
9+
"fmt"
10+
11+
"github.com/elastic/cloud-on-k8s/pkg/utils/set"
12+
)
13+
14+
type ServiceAccountCredential struct {
15+
NodesCredentials NodesCredentials `json:"nodes_credentials"`
16+
}
17+
18+
type NodesCredentials struct {
19+
FileTokens map[string]FileToken `json:"file_tokens"`
20+
}
21+
22+
type FileToken struct {
23+
Nodes []string `json:"nodes"`
24+
}
25+
26+
// Nodes returns the list of nodes which are referenced in the API response.
27+
func (s *ServiceAccountCredential) Nodes() set.StringSet {
28+
result := set.Make()
29+
for _, fileToken := range s.NodesCredentials.FileTokens {
30+
for _, nodeName := range fileToken.Nodes {
31+
result.Add(nodeName)
32+
}
33+
}
34+
return result
35+
}
36+
37+
type SecurityClient interface {
38+
39+
// GetServiceAccountCredentials returns the service account credentials from the /_security/service API
40+
GetServiceAccountCredentials(ctx context.Context, namespacedService string) (ServiceAccountCredential, error)
41+
}
42+
43+
func (c *clientV6) GetServiceAccountCredentials(_ context.Context, _ string) (ServiceAccountCredential, error) {
44+
return ServiceAccountCredential{}, errNotSupportedInEs6x
45+
}
46+
47+
func (c *clientV7) GetServiceAccountCredentials(ctx context.Context, namespacedService string) (ServiceAccountCredential, error) {
48+
var serviceAccountCredential ServiceAccountCredential
49+
path := fmt.Sprintf("/_security/service/%s/credential", namespacedService)
50+
if err := c.get(ctx, path, &serviceAccountCredential); err != nil {
51+
return serviceAccountCredential, err
52+
}
53+
return serviceAccountCredential, nil
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
package client
6+
7+
import (
8+
"context"
9+
"io/ioutil"
10+
"net/http"
11+
"reflect"
12+
"strings"
13+
"testing"
14+
15+
"github.com/stretchr/testify/require"
16+
17+
"github.com/elastic/cloud-on-k8s/pkg/controller/common/version"
18+
)
19+
20+
func Test_GetServiceAccountCredentials(t *testing.T) {
21+
type args struct {
22+
namespacedService string
23+
}
24+
tests := []struct {
25+
name string
26+
client Client
27+
args args
28+
want ServiceAccountCredential
29+
wantErr bool
30+
}{
31+
{
32+
args: args{namespacedService: "elastic/kibana"},
33+
want: ServiceAccountCredential{NodesCredentials: NodesCredentials{
34+
FileTokens: map[string]FileToken{
35+
"default_kibana-sample_50d2f2d2-d989-4ab7-a3d4-c9e31e5651ca": {Nodes: []string{"elasticsearch-sample-es-default-0"}},
36+
}},
37+
},
38+
client: NewMockClient(version.MustParse("7.17.0"), func(req *http.Request) *http.Response {
39+
require.Equal(t, "/_security/service/elastic/kibana/credential", req.URL.Path)
40+
return &http.Response{
41+
StatusCode: 200,
42+
Body: ioutil.NopCloser(strings.NewReader(
43+
`{
44+
"service_account": "elastic/kibana",
45+
"count": 1,
46+
"tokens": {},
47+
"nodes_credentials": {
48+
"_nodes": {
49+
"total": 1,
50+
"successful": 1,
51+
"failed": 0
52+
},
53+
"file_tokens": {
54+
"default_kibana-sample_50d2f2d2-d989-4ab7-a3d4-c9e31e5651ca": {
55+
"nodes": ["elasticsearch-sample-es-default-0"]
56+
}
57+
}
58+
}
59+
}`)),
60+
Header: make(http.Header),
61+
Request: req,
62+
}
63+
}),
64+
},
65+
}
66+
for _, tt := range tests {
67+
t.Run(tt.name, func(t *testing.T) {
68+
got, err := tt.client.GetServiceAccountCredentials(context.TODO(), tt.args.namespacedService)
69+
if (err != nil) != tt.wantErr {
70+
t.Errorf("clientV7.GetServiceAccountCredentials() error = %v, wantErr %v", err, tt.wantErr)
71+
return
72+
}
73+
if !reflect.DeepEqual(got, tt.want) {
74+
t.Errorf("client.GetServiceAccountCredentials() = %v, want %v", got, tt.want)
75+
}
76+
})
77+
}
78+
}

pkg/controller/elasticsearch/driver/driver.go

+114
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/pkg/errors"
1515
corev1 "k8s.io/api/core/v1"
1616
"k8s.io/client-go/tools/record"
17+
"k8s.io/utils/pointer"
1718
controller "sigs.k8s.io/controller-runtime/pkg/reconcile"
1819

1920
esv1 "github.com/elastic/cloud-on-k8s/pkg/apis/elasticsearch/v1"
@@ -33,6 +34,7 @@ import (
3334
"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/cleanup"
3435
esclient "github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/client"
3536
"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/configmap"
37+
"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/hints"
3638
"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/initcontainer"
3739
"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/label"
3840
"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/license"
@@ -44,6 +46,8 @@ import (
4446
"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/stackmon"
4547
"github.com/elastic/cloud-on-k8s/pkg/controller/elasticsearch/user"
4648
"github.com/elastic/cloud-on-k8s/pkg/utils/k8s"
49+
"github.com/elastic/cloud-on-k8s/pkg/utils/optional"
50+
"github.com/elastic/cloud-on-k8s/pkg/utils/set"
4751
)
4852

4953
var (
@@ -257,6 +261,10 @@ func (d *defaultDriver) Reconcile(ctx context.Context) *reconciler.Results {
257261
}
258262
}
259263

264+
// Update the service account orchestration hint. This is done early in the reconciliation loop to unblock association
265+
// controllers that may be waiting for the orchestration hint.
266+
results.WithError(d.maybeSetServiceAccountsOrchestrationHint(ctx, esReachable, esClient, resourcesState))
267+
260268
// reconcile the Elasticsearch license
261269
if esReachable {
262270
err = license.Reconcile(ctx, d.Client, d.ES, esClient, currentLicense)
@@ -354,6 +362,112 @@ func (d *defaultDriver) newElasticsearchClient(
354362
)
355363
}
356364

365+
// maybeSetServiceAccountsOrchestrationHint attempts to update an orchestration hint to let the association controllers
366+
// know whether all the nodes in the cluster are ready to authenticate service accounts.
367+
func (d *defaultDriver) maybeSetServiceAccountsOrchestrationHint(
368+
ctx context.Context,
369+
esReachable bool,
370+
securityClient esclient.SecurityClient,
371+
resourcesState *reconcile.ResourcesState,
372+
) error {
373+
if d.ReconcileState.OrchestrationHints().ServiceAccounts.IsTrue() {
374+
// Orchestration hint is already set to true, there is no point going back to false.
375+
return nil
376+
}
377+
378+
// Case 1: New cluster, we can immediately set the orchestration hint.
379+
if !bootstrap.AnnotatedForBootstrap(d.ES) {
380+
allNodesRunningServiceAccounts, err := esv1.AreServiceAccountsSupported(d.ES.Spec.Version)
381+
if err != nil {
382+
return err
383+
}
384+
d.ReconcileState.UpdateOrchestrationHints(
385+
d.ReconcileState.OrchestrationHints().Merge(hints.OrchestrationsHints{ServiceAccounts: optional.NewBool(allNodesRunningServiceAccounts)}),
386+
)
387+
return nil
388+
}
389+
390+
// Case 2: This is an existing cluster, but actual cluster version does not support service accounts.
391+
if d.ES.Status.Version == "" {
392+
return nil
393+
}
394+
supportServiceAccounts, err := esv1.AreServiceAccountsSupported(d.ES.Status.Version)
395+
if err != nil {
396+
return err
397+
}
398+
if !supportServiceAccounts {
399+
d.ReconcileState.UpdateOrchestrationHints(
400+
d.ReconcileState.OrchestrationHints().Merge(hints.OrchestrationsHints{ServiceAccounts: optional.NewBool(false)}),
401+
)
402+
return nil
403+
}
404+
405+
// Case 3: cluster is already running with a version that does support service account and tokens have already been created.
406+
// We don't however know if all nodes have been migrated and are running with the service_tokens file mounted from the configuration Secret.
407+
// Let's try to detect that situation by comparing the existing nodes and the ones returned by the /_security/service API.
408+
// Note that starting with release 2.3 the association controller does not create the service account token until Elasticsearch is annotated
409+
// as compatible with service accounts. This is mostly to unblock situation described in https://github.com/elastic/cloud-on-k8s/issues/5684
410+
if !esReachable {
411+
// This requires the Elasticsearch API to be available
412+
return nil
413+
}
414+
allPods := names(resourcesState.AllPods)
415+
// Detect if some service tokens are expected
416+
saTokens, err := user.GetServiceAccountTokens(d.Client, d.ES)
417+
if err != nil {
418+
log.Info("Could not detect if service accounts are expected", "err", err, "namespace", d.ES.Namespace, "es_name", d.ES.Name)
419+
return err
420+
}
421+
422+
allNodesRunningServiceAccounts, err := allNodesRunningServiceAccounts(ctx, saTokens, set.Make(allPods...), securityClient)
423+
if err != nil {
424+
log.Info("Could not detect if all nodes are ready for using service accounts", "err", err, "namespace", d.ES.Namespace, "es_name", d.ES.Name)
425+
return err
426+
}
427+
if allNodesRunningServiceAccounts != nil {
428+
d.ReconcileState.UpdateOrchestrationHints(
429+
d.ReconcileState.OrchestrationHints().Merge(hints.OrchestrationsHints{ServiceAccounts: optional.NewBool(*allNodesRunningServiceAccounts)}),
430+
)
431+
}
432+
433+
return nil
434+
}
435+
436+
// allNodesRunningServiceAccounts attempts to detect if all the nodes in the clusters have loaded the service_tokens file.
437+
// It returns nil if no decision can be made, for example when there is no tokens are expected to be found.
438+
func allNodesRunningServiceAccounts(
439+
ctx context.Context,
440+
saTokens user.ServiceAccountTokens,
441+
allPods set.StringSet,
442+
securityClient esclient.SecurityClient,
443+
) (*bool, error) {
444+
if len(allPods) == 0 {
445+
return nil, nil
446+
}
447+
if len(saTokens) == 0 {
448+
// No tokens are expected: we cannot call the Elasticsearch API to detect which nodes are
449+
// running with the conf/service_tokens file.
450+
return nil, nil
451+
}
452+
453+
// Get the namespaced service name to call the /_security/service/<namespace>/<service>/credential API
454+
namespacedServices := saTokens.NamespacedServices()
455+
456+
// Get the nodes which have loaded tokens from the conf/service_tokens file.
457+
for namespacedService := range namespacedServices {
458+
credentials, err := securityClient.GetServiceAccountCredentials(ctx, namespacedService)
459+
if err != nil {
460+
return nil, err
461+
}
462+
diff := allPods.Diff(credentials.Nodes())
463+
if len(diff) == 0 {
464+
return pointer.Bool(true), nil
465+
}
466+
}
467+
// Some nodes are running but did not show up in the security API.
468+
return pointer.Bool(false), nil
469+
}
470+
357471
// warnUnsupportedDistro sends an event of type warning if the Elasticsearch Docker image is not a supported
358472
// distribution by looking at if the prepare fs init container terminated with the UnsupportedDistro exit code.
359473
func warnUnsupportedDistro(pods []corev1.Pod, recorder *events.Recorder) {

0 commit comments

Comments
 (0)