k8s.io/apiserver@v0.31.1/pkg/server/options/encryptionconfig/controller/controller_test.go (about)

     1  /*
     2  Copyright 2022 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package controller
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"net/http"
    23  	"strings"
    24  	"sync"
    25  	"sync/atomic"
    26  	"testing"
    27  	"time"
    28  
    29  	"k8s.io/apiserver/pkg/features"
    30  	"k8s.io/apiserver/pkg/server/healthz"
    31  	"k8s.io/apiserver/pkg/server/options/encryptionconfig"
    32  	utilfeature "k8s.io/apiserver/pkg/util/feature"
    33  	"k8s.io/client-go/util/workqueue"
    34  	featuregatetesting "k8s.io/component-base/featuregate/testing"
    35  	"k8s.io/component-base/metrics/legacyregistry"
    36  	"k8s.io/component-base/metrics/testutil"
    37  )
    38  
    39  func TestController(t *testing.T) {
    40  	origMinKMSPluginCloseGracePeriod := minKMSPluginCloseGracePeriod
    41  	t.Cleanup(func() { minKMSPluginCloseGracePeriod = origMinKMSPluginCloseGracePeriod })
    42  	minKMSPluginCloseGracePeriod = 300 * time.Millisecond
    43  
    44  	featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.KMSv1, true)
    45  
    46  	const expectedSuccessMetricValue = `
    47  # HELP apiserver_encryption_config_controller_automatic_reload_success_total [ALPHA] Total number of successful automatic reloads of encryption configuration split by apiserver identity.
    48  # TYPE apiserver_encryption_config_controller_automatic_reload_success_total counter
    49  apiserver_encryption_config_controller_automatic_reload_success_total{apiserver_id_hash="sha256:cd8a60cec6134082e9f37e7a4146b4bc14a0bf8a863237c36ec8fdb658c3e027"} 1
    50  # HELP apiserver_encryption_config_controller_automatic_reloads_total [ALPHA] Total number of reload successes and failures of encryption configuration split by apiserver identity.
    51  # TYPE apiserver_encryption_config_controller_automatic_reloads_total counter
    52  apiserver_encryption_config_controller_automatic_reloads_total{apiserver_id_hash="sha256:cd8a60cec6134082e9f37e7a4146b4bc14a0bf8a863237c36ec8fdb658c3e027",status="success"} 1
    53  `
    54  	const expectedFailureMetricValue = `
    55  # HELP apiserver_encryption_config_controller_automatic_reload_failures_total [ALPHA] Total number of failed automatic reloads of encryption configuration split by apiserver identity.
    56  # TYPE apiserver_encryption_config_controller_automatic_reload_failures_total counter
    57  apiserver_encryption_config_controller_automatic_reload_failures_total{apiserver_id_hash="sha256:cd8a60cec6134082e9f37e7a4146b4bc14a0bf8a863237c36ec8fdb658c3e027"} 1
    58  # HELP apiserver_encryption_config_controller_automatic_reloads_total [ALPHA] Total number of reload successes and failures of encryption configuration split by apiserver identity.
    59  # TYPE apiserver_encryption_config_controller_automatic_reloads_total counter
    60  apiserver_encryption_config_controller_automatic_reloads_total{apiserver_id_hash="sha256:cd8a60cec6134082e9f37e7a4146b4bc14a0bf8a863237c36ec8fdb658c3e027",status="failure"} 1
    61  `
    62  
    63  	tests := []struct {
    64  		name                        string
    65  		wantECFileHash              string
    66  		wantTransformerClosed       bool
    67  		wantLoadCalls               int
    68  		wantHashCalls               int
    69  		wantAddRateLimitedCount     uint64
    70  		wantMetrics                 string
    71  		mockLoadEncryptionConfig    func(ctx context.Context, filepath string, reload bool, apiServerID string) (*encryptionconfig.EncryptionConfiguration, error)
    72  		mockGetEncryptionConfigHash func(ctx context.Context, filepath string) (string, error)
    73  	}{
    74  		{
    75  			name:                    "when invalid config is provided previous config shouldn't be changed",
    76  			wantECFileHash:          "k8s:enc:unstable:1:6bc9f4aa2e5587afbb96074e1809550cbc4de3cc3a35717dac8ff2800a147fd3",
    77  			wantLoadCalls:           1,
    78  			wantHashCalls:           1,
    79  			wantTransformerClosed:   true,
    80  			wantMetrics:             expectedFailureMetricValue,
    81  			wantAddRateLimitedCount: 1,
    82  			mockGetEncryptionConfigHash: func(ctx context.Context, filepath string) (string, error) {
    83  				return "always changes and never errors", nil
    84  			},
    85  			mockLoadEncryptionConfig: func(ctx context.Context, filepath string, reload bool, apiServerID string) (*encryptionconfig.EncryptionConfiguration, error) {
    86  				return nil, fmt.Errorf("empty config file")
    87  			},
    88  		},
    89  		{
    90  			name:                    "when new valid config is provided it should be updated",
    91  			wantECFileHash:          "some new config hash",
    92  			wantLoadCalls:           1,
    93  			wantHashCalls:           1,
    94  			wantMetrics:             expectedSuccessMetricValue,
    95  			wantAddRateLimitedCount: 0,
    96  			mockGetEncryptionConfigHash: func(ctx context.Context, filepath string) (string, error) {
    97  				return "always changes and never errors", nil
    98  			},
    99  			mockLoadEncryptionConfig: func(ctx context.Context, filepath string, reload bool, apiServerID string) (*encryptionconfig.EncryptionConfiguration, error) {
   100  				return &encryptionconfig.EncryptionConfiguration{
   101  					HealthChecks: []healthz.HealthChecker{
   102  						&mockHealthChecker{
   103  							pluginName: "valid-plugin",
   104  							err:        nil,
   105  						},
   106  					},
   107  					EncryptionFileContentHash: "some new config hash",
   108  				}, nil
   109  			},
   110  		},
   111  		{
   112  			name:                    "when same valid config is provided previous config shouldn't be changed",
   113  			wantECFileHash:          "k8s:enc:unstable:1:6bc9f4aa2e5587afbb96074e1809550cbc4de3cc3a35717dac8ff2800a147fd3",
   114  			wantLoadCalls:           1,
   115  			wantHashCalls:           1,
   116  			wantTransformerClosed:   true,
   117  			wantMetrics:             "",
   118  			wantAddRateLimitedCount: 0,
   119  			mockGetEncryptionConfigHash: func(ctx context.Context, filepath string) (string, error) {
   120  				return "always changes and never errors", nil
   121  			},
   122  			mockLoadEncryptionConfig: func(ctx context.Context, filepath string, reload bool, apiServerID string) (*encryptionconfig.EncryptionConfiguration, error) {
   123  				return &encryptionconfig.EncryptionConfiguration{
   124  					HealthChecks: []healthz.HealthChecker{
   125  						&mockHealthChecker{
   126  							pluginName: "valid-plugin",
   127  							err:        nil,
   128  						},
   129  					},
   130  					// hash of initial "testdata/ec_config.yaml" config file before reloading
   131  					EncryptionFileContentHash: "k8s:enc:unstable:1:6bc9f4aa2e5587afbb96074e1809550cbc4de3cc3a35717dac8ff2800a147fd3",
   132  				}, nil
   133  			},
   134  		},
   135  		{
   136  			name:                    "when transformer's health check fails previous config shouldn't be changed",
   137  			wantECFileHash:          "k8s:enc:unstable:1:6bc9f4aa2e5587afbb96074e1809550cbc4de3cc3a35717dac8ff2800a147fd3",
   138  			wantLoadCalls:           1,
   139  			wantHashCalls:           1,
   140  			wantTransformerClosed:   true,
   141  			wantMetrics:             expectedFailureMetricValue,
   142  			wantAddRateLimitedCount: 1,
   143  			mockGetEncryptionConfigHash: func(ctx context.Context, filepath string) (string, error) {
   144  				return "always changes and never errors", nil
   145  			},
   146  			mockLoadEncryptionConfig: func(ctx context.Context, filepath string, reload bool, apiServerID string) (*encryptionconfig.EncryptionConfiguration, error) {
   147  				return &encryptionconfig.EncryptionConfiguration{
   148  					HealthChecks: []healthz.HealthChecker{
   149  						&mockHealthChecker{
   150  							pluginName: "invalid-plugin",
   151  							err:        fmt.Errorf("mockingly failing"),
   152  						},
   153  					},
   154  					KMSCloseGracePeriod:       0, // use minKMSPluginCloseGracePeriod
   155  					EncryptionFileContentHash: "anything different",
   156  				}, nil
   157  			},
   158  		},
   159  		{
   160  			name:                    "when multiple health checks are present previous config shouldn't be changed",
   161  			wantECFileHash:          "k8s:enc:unstable:1:6bc9f4aa2e5587afbb96074e1809550cbc4de3cc3a35717dac8ff2800a147fd3",
   162  			wantLoadCalls:           1,
   163  			wantHashCalls:           1,
   164  			wantTransformerClosed:   true,
   165  			wantMetrics:             expectedFailureMetricValue,
   166  			wantAddRateLimitedCount: 1,
   167  			mockGetEncryptionConfigHash: func(ctx context.Context, filepath string) (string, error) {
   168  				return "always changes and never errors", nil
   169  			},
   170  			mockLoadEncryptionConfig: func(ctx context.Context, filepath string, reload bool, apiServerID string) (*encryptionconfig.EncryptionConfiguration, error) {
   171  				return &encryptionconfig.EncryptionConfiguration{
   172  					HealthChecks: []healthz.HealthChecker{
   173  						&mockHealthChecker{
   174  							pluginName: "valid-plugin",
   175  							err:        nil,
   176  						},
   177  						&mockHealthChecker{
   178  							pluginName: "another-valid-plugin",
   179  							err:        nil,
   180  						},
   181  					},
   182  					EncryptionFileContentHash: "anything different",
   183  				}, nil
   184  			},
   185  		},
   186  		{
   187  			name:                    "when invalid health check URL is provided previous config shouldn't be changed",
   188  			wantECFileHash:          "k8s:enc:unstable:1:6bc9f4aa2e5587afbb96074e1809550cbc4de3cc3a35717dac8ff2800a147fd3",
   189  			wantLoadCalls:           1,
   190  			wantHashCalls:           1,
   191  			wantTransformerClosed:   true,
   192  			wantMetrics:             expectedFailureMetricValue,
   193  			wantAddRateLimitedCount: 1,
   194  			mockGetEncryptionConfigHash: func(ctx context.Context, filepath string) (string, error) {
   195  				return "always changes and never errors", nil
   196  			},
   197  			mockLoadEncryptionConfig: func(ctx context.Context, filepath string, reload bool, apiServerID string) (*encryptionconfig.EncryptionConfiguration, error) {
   198  				return &encryptionconfig.EncryptionConfiguration{
   199  					HealthChecks: []healthz.HealthChecker{
   200  						&mockHealthChecker{
   201  							pluginName: "invalid\nname",
   202  							err:        nil,
   203  						},
   204  					},
   205  					EncryptionFileContentHash: "anything different",
   206  				}, nil
   207  			},
   208  		},
   209  		{
   210  			name:                    "when config is not updated transformers are closed correctly",
   211  			wantECFileHash:          "k8s:enc:unstable:1:6bc9f4aa2e5587afbb96074e1809550cbc4de3cc3a35717dac8ff2800a147fd3",
   212  			wantLoadCalls:           1,
   213  			wantHashCalls:           1,
   214  			wantTransformerClosed:   true,
   215  			wantMetrics:             "",
   216  			wantAddRateLimitedCount: 0,
   217  			mockGetEncryptionConfigHash: func(ctx context.Context, filepath string) (string, error) {
   218  				return "always changes and never errors", nil
   219  			},
   220  			mockLoadEncryptionConfig: func(ctx context.Context, filepath string, reload bool, apiServerID string) (*encryptionconfig.EncryptionConfiguration, error) {
   221  				return &encryptionconfig.EncryptionConfiguration{
   222  					HealthChecks: []healthz.HealthChecker{
   223  						&mockHealthChecker{
   224  							pluginName: "valid-plugin",
   225  							err:        nil,
   226  						},
   227  					},
   228  					// hash of initial "testdata/ec_config.yaml" config file before reloading
   229  					EncryptionFileContentHash: "k8s:enc:unstable:1:6bc9f4aa2e5587afbb96074e1809550cbc4de3cc3a35717dac8ff2800a147fd3",
   230  				}, nil
   231  			},
   232  		},
   233  		{
   234  			name:                    "when config hash is not updated transformers are closed correctly",
   235  			wantECFileHash:          "k8s:enc:unstable:1:6bc9f4aa2e5587afbb96074e1809550cbc4de3cc3a35717dac8ff2800a147fd3",
   236  			wantLoadCalls:           0,
   237  			wantHashCalls:           1,
   238  			wantTransformerClosed:   true,
   239  			wantMetrics:             "",
   240  			wantAddRateLimitedCount: 0,
   241  			mockGetEncryptionConfigHash: func(ctx context.Context, filepath string) (string, error) {
   242  				// hash of initial "testdata/ec_config.yaml" config file before reloading
   243  				return "k8s:enc:unstable:1:6bc9f4aa2e5587afbb96074e1809550cbc4de3cc3a35717dac8ff2800a147fd3", nil
   244  			},
   245  			mockLoadEncryptionConfig: func(ctx context.Context, filepath string, reload bool, apiServerID string) (*encryptionconfig.EncryptionConfiguration, error) {
   246  				return nil, fmt.Errorf("should not be called")
   247  			},
   248  		},
   249  		{
   250  			name:                    "when config hash errors transformers are closed correctly",
   251  			wantECFileHash:          "k8s:enc:unstable:1:6bc9f4aa2e5587afbb96074e1809550cbc4de3cc3a35717dac8ff2800a147fd3",
   252  			wantLoadCalls:           0,
   253  			wantHashCalls:           1,
   254  			wantTransformerClosed:   true,
   255  			wantMetrics:             expectedFailureMetricValue,
   256  			wantAddRateLimitedCount: 1,
   257  			mockGetEncryptionConfigHash: func(ctx context.Context, filepath string) (string, error) {
   258  				return "", fmt.Errorf("some io error")
   259  			},
   260  			mockLoadEncryptionConfig: func(ctx context.Context, filepath string, reload bool, apiServerID string) (*encryptionconfig.EncryptionConfiguration, error) {
   261  				return nil, fmt.Errorf("should not be called")
   262  			},
   263  		},
   264  	}
   265  
   266  	for _, test := range tests {
   267  		t.Run(test.name, func(t *testing.T) {
   268  			ctxServer, closeServer := context.WithCancel(context.Background())
   269  			ctxTransformers, closeTransformers := context.WithCancel(ctxServer)
   270  			t.Cleanup(closeServer)
   271  			t.Cleanup(closeTransformers)
   272  
   273  			legacyregistry.Reset()
   274  
   275  			// load initial encryption config
   276  			encryptionConfiguration, err := encryptionconfig.LoadEncryptionConfig(
   277  				ctxTransformers,
   278  				"testdata/ec_config.yaml",
   279  				true,
   280  				"test-apiserver",
   281  			)
   282  			if err != nil {
   283  				t.Fatalf("failed to load encryption config: %v", err)
   284  			}
   285  
   286  			d := NewDynamicEncryptionConfiguration(
   287  				"test-controller",
   288  				"does not matter",
   289  				encryptionconfig.NewDynamicTransformers(
   290  					encryptionConfiguration.Transformers,
   291  					encryptionConfiguration.HealthChecks[0],
   292  					closeTransformers,
   293  					0, // set grace period to 0 so that the time.Sleep in DynamicTransformers.Set finishes quickly
   294  				),
   295  				encryptionConfiguration.EncryptionFileContentHash,
   296  				"test-apiserver",
   297  			)
   298  			d.queue.ShutDown() // we do not use the real queue during tests
   299  
   300  			queue := &mockWorkQueue{
   301  				addCalled: make(chan struct{}),
   302  				cancel:    closeServer,
   303  			}
   304  			d.queue = queue
   305  
   306  			var hashCalls, loadCalls int
   307  			d.loadEncryptionConfig = func(ctx context.Context, filepath string, reload bool, apiServerID string) (*encryptionconfig.EncryptionConfiguration, error) {
   308  				loadCalls++
   309  				queue.ctx = ctx
   310  				return test.mockLoadEncryptionConfig(ctx, filepath, reload, apiServerID)
   311  			}
   312  			d.getEncryptionConfigHash = func(ctx context.Context, filepath string) (string, error) {
   313  				hashCalls++
   314  				queue.ctx = ctx
   315  				return test.mockGetEncryptionConfigHash(ctx, filepath)
   316  			}
   317  
   318  			d.Run(ctxServer) // this should block and run exactly one iteration of the worker loop
   319  
   320  			if test.wantECFileHash != d.lastLoadedEncryptionConfigHash {
   321  				t.Errorf("expected encryption config hash %q but got %q", test.wantECFileHash, d.lastLoadedEncryptionConfigHash)
   322  			}
   323  
   324  			if test.wantLoadCalls != loadCalls {
   325  				t.Errorf("load calls does not match: want=%v, got=%v", test.wantLoadCalls, loadCalls)
   326  			}
   327  
   328  			if test.wantHashCalls != hashCalls {
   329  				t.Errorf("hash calls does not match: want=%v, got=%v", test.wantHashCalls, hashCalls)
   330  			}
   331  
   332  			if test.wantTransformerClosed != queue.wasCanceled {
   333  				t.Errorf("transformer closed does not match: want=%v, got=%v", test.wantTransformerClosed, queue.wasCanceled)
   334  			}
   335  
   336  			if test.wantAddRateLimitedCount != queue.addRateLimitedCount.Load() {
   337  				t.Errorf("queue addRateLimitedCount does not match: want=%v, got=%v", test.wantAddRateLimitedCount, queue.addRateLimitedCount.Load())
   338  			}
   339  
   340  			if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(test.wantMetrics),
   341  				"apiserver_encryption_config_controller_automatic_reload_success_total",
   342  				"apiserver_encryption_config_controller_automatic_reload_failures_total",
   343  				"apiserver_encryption_config_controller_automatic_reloads_total",
   344  			); err != nil {
   345  				t.Errorf("failed to validate metrics: %v", err)
   346  			}
   347  		})
   348  	}
   349  }
   350  
   351  type mockWorkQueue struct {
   352  	workqueue.TypedRateLimitingInterface[string] // will panic if any unexpected method is called
   353  
   354  	closeOnce sync.Once
   355  	addCalled chan struct{}
   356  
   357  	count       atomic.Uint64
   358  	ctx         context.Context
   359  	wasCanceled bool
   360  	cancel      func()
   361  
   362  	addRateLimitedCount atomic.Uint64
   363  }
   364  
   365  func (m *mockWorkQueue) Done(item string) {
   366  	m.count.Add(1)
   367  	m.wasCanceled = m.ctx.Err() != nil
   368  	m.cancel()
   369  }
   370  
   371  func (m *mockWorkQueue) Get() (item string, shutdown bool) {
   372  	<-m.addCalled
   373  
   374  	switch m.count.Load() {
   375  	case 0:
   376  		return "", false
   377  	case 1:
   378  		return "", true
   379  	default:
   380  		panic("too many calls to Get")
   381  	}
   382  }
   383  
   384  func (m *mockWorkQueue) Add(item string) {
   385  	m.closeOnce.Do(func() {
   386  		close(m.addCalled)
   387  	})
   388  }
   389  
   390  func (m *mockWorkQueue) ShutDown()                  {}
   391  func (m *mockWorkQueue) AddRateLimited(item string) { m.addRateLimitedCount.Add(1) }
   392  
   393  type mockHealthChecker struct {
   394  	pluginName string
   395  	err        error
   396  }
   397  
   398  func (m *mockHealthChecker) Check(req *http.Request) error {
   399  	return m.err
   400  }
   401  
   402  func (m *mockHealthChecker) Name() string {
   403  	return m.pluginName
   404  }