Skip to content

Commit 3a408b8

Browse files
Merge pull request #2298 from ibihim/CNTRLPLANE-3234-health-reporter-reader
pkg/operator/encryption/kms/health: add KMS plugin health checker
2 parents acbfa3c + 812dbbf commit 3a408b8

4 files changed

Lines changed: 298 additions & 8 deletions

File tree

pkg/operator/encryption/kms/health/cmd.go

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,29 @@ import (
1010
"github.com/spf13/cobra"
1111
"github.com/spf13/pflag"
1212

13+
"k8s.io/apimachinery/pkg/util/wait"
14+
"k8s.io/apiserver/pkg/server"
15+
k8senvelopekmsv2 "k8s.io/apiserver/pkg/storage/value/encrypt/envelope/kmsv2"
1316
"k8s.io/client-go/rest"
1417
"k8s.io/client-go/tools/clientcmd"
1518
"k8s.io/klog/v2"
1619
)
1720

21+
const providerName = "kms-health-reporter"
22+
1823
// kmsSocketPattern matches the socket path each co-located KMSv2 plugin is
1924
// mounted at, e.g. unix:///var/run/kmsplugin/kms-1.sock.
20-
var kmsSocketPattern = regexp.MustCompile(`^unix:///var/run/kmsplugin/kms-\d+\.sock$`)
25+
var kmsSocketPattern = regexp.MustCompile(`^unix:///var/run/kmsplugin/kms-(\d+)\.sock$`)
26+
27+
// keyIDFromSocket extracts the sequential key id captured by kmsSocketPattern,
28+
// e.g. "1" from unix:///var/run/kmsplugin/kms-1.sock.
29+
func keyIDFromSocket(socket string) (string, error) {
30+
m := kmsSocketPattern.FindStringSubmatch(socket)
31+
if m == nil {
32+
return "", fmt.Errorf("socket %q must match %s", socket, kmsSocketPattern)
33+
}
34+
return m[1], nil
35+
}
2136

2237
// options' flag-bound fields are exported so the struct can be logged as a
2338
// whole via klog.InfoS, which JSON-marshals its values.
@@ -45,7 +60,7 @@ func NewCommand(ctx context.Context, newOperatorClient func(*rest.Config) (v1hel
4560
if err := o.validate(); err != nil {
4661
return err
4762
}
48-
return o.run()
63+
return o.run(ctx)
4964
},
5065
}
5166
o.addFlags(cmd.Flags())
@@ -65,10 +80,15 @@ func (o *options) validate() error {
6580
if len(o.KMSSockets) == 0 {
6681
return fmt.Errorf("--kms-sockets is required, at least one")
6782
}
83+
socketSet := make(map[string]struct{}, len(o.KMSSockets))
6884
for _, s := range o.KMSSockets {
6985
if !kmsSocketPattern.MatchString(s) {
7086
return fmt.Errorf("--kms-sockets entry %q must match %s", s, kmsSocketPattern)
7187
}
88+
if _, ok := socketSet[s]; ok {
89+
return fmt.Errorf("--kms-sockets entry %q is duplicated", s)
90+
}
91+
socketSet[s] = struct{}{}
7292
}
7393

7494
if o.Interval <= 0 {
@@ -87,8 +107,12 @@ func (o *options) validate() error {
87107
return nil
88108
}
89109

90-
func (o *options) run() error {
91-
cfg, err := buildRESTConfig(o.Kubeconfig)
110+
func (o *options) run(ctx context.Context) error {
111+
ctx = setupSignalContext(ctx)
112+
113+
// Empty kubeconfig falls back to the in-cluster config (service account
114+
// token + KUBERNETES_SERVICE_HOST), which is the deployed path.
115+
cfg, err := clientcmd.BuildConfigFromFlags("", o.Kubeconfig)
92116
if err != nil {
93117
return fmt.Errorf("build rest config: %w", err)
94118
}
@@ -97,14 +121,58 @@ func (o *options) run() error {
97121
return fmt.Errorf("build operator client: %w", err)
98122
}
99123

124+
plugins, err := buildPlugins(ctx, o.KMSSockets, o.ReadTimeout)
125+
if err != nil {
126+
return err
127+
}
128+
prober := newProber(plugins)
129+
100130
klog.InfoS("kms-health-reporter starting", "config", o)
101131

132+
wait.JitterUntilWithContext(ctx, func(ctx context.Context) {
133+
// Each Status RPC enforces o.ReadTimeout internally (set at dial time);
134+
// ctx here only carries shutdown cancellation.
135+
conditions := prober.probeAll(ctx)
136+
// TODO: hand conditions to the writer once it lands; logging is a placeholder.
137+
klog.InfoS("kms plugin health", "conditions", conditions)
138+
}, o.Interval, 0.1, false)
139+
102140
return nil
103141
}
104142

105-
func buildRESTConfig(kubeconfig string) (*rest.Config, error) {
106-
if kubeconfig != "" {
107-
return clientcmd.BuildConfigFromFlags("", kubeconfig)
143+
func buildPlugins(ctx context.Context, sockets []string, timeout time.Duration) ([]pluginClient, error) {
144+
plugins := make([]pluginClient, 0, len(sockets))
145+
146+
for _, socket := range sockets {
147+
keyID, err := keyIDFromSocket(socket)
148+
if err != nil {
149+
return nil, err
150+
}
151+
152+
// Unique name per plugin so the gRPC client's KMS operation metrics
153+
// don't merge both plugins into one series.
154+
service, err := k8senvelopekmsv2.NewGRPCService(ctx, socket, providerName+"-"+keyID, timeout)
155+
if err != nil {
156+
// With the current dependency version this should never happen with a validated GRPC endpoint.
157+
return nil, fmt.Errorf("setting up grpc service failed at %q: %w", socket, err)
158+
}
159+
160+
plugins = append(plugins, pluginClient{keyID: keyID, service: service})
108161
}
109-
return rest.InClusterConfig()
162+
163+
return plugins, nil
164+
}
165+
166+
// setupSignalContext registers for SIGTERM and SIGINT and returns a context
167+
// that will be cancelled once a signal is received. Compare startupmonitor's
168+
// setupSignalContext.
169+
func setupSignalContext(baseCtx context.Context) context.Context {
170+
shutdownCtx, cancel := context.WithCancel(baseCtx)
171+
shutdownHandler := server.SetupSignalHandler()
172+
go func() {
173+
defer cancel()
174+
<-shutdownHandler
175+
klog.Infof("Received SIGTERM or SIGINT signal, shutting down the process.")
176+
}()
177+
return shutdownCtx
110178
}

pkg/operator/encryption/kms/health/cmd_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ func TestValidate(t *testing.T) {
4343
name: "multiple valid sockets",
4444
mutate: func(o *options) { o.KMSSockets = append(o.KMSSockets, "unix:///var/run/kmsplugin/kms-2.sock") },
4545
},
46+
{
47+
name: "duplicate sockets",
48+
mutate: func(o *options) { o.KMSSockets = append(o.KMSSockets, o.KMSSockets[0]) },
49+
wantErr: true,
50+
},
4651
{
4752
name: "socket missing unix scheme",
4853
mutate: func(o *options) { o.KMSSockets = []string{"/var/run/kmsplugin/kms-1.sock"} },
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package health
2+
3+
import (
4+
"context"
5+
"sync"
6+
"time"
7+
8+
kmsservice "k8s.io/kms/pkg/service"
9+
)
10+
11+
// healthzOK is the value the KMS plugin returns when healthy.
12+
// See https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/kms/apis/v2/api.proto#L39
13+
const healthzOK = "ok"
14+
15+
const (
16+
statusHealthy = "healthy"
17+
statusUnhealthy = "unhealthy"
18+
statusError = "error"
19+
)
20+
21+
type pluginHealthReport struct {
22+
// KeyID is the controller's sequential key id; KEKID is the KMS provider's
23+
// encryption key id. Distinct identifiers, easy to confuse.
24+
KeyID string
25+
KEKID string
26+
Status string
27+
LastChecked time.Time
28+
Detail string
29+
}
30+
31+
// pluginClient is the dialed handle to one co-located KMS plugin; the plugin
32+
// itself is a separate process behind the unix socket.
33+
type pluginClient struct {
34+
keyID string
35+
service kmsservice.Service
36+
}
37+
38+
type prober struct {
39+
plugins []pluginClient
40+
now func() time.Time
41+
}
42+
43+
func newProber(plugins []pluginClient) *prober {
44+
return &prober{
45+
plugins: plugins,
46+
now: time.Now,
47+
}
48+
}
49+
50+
// probeAll never returns an error: a failed probe is encoded as a report
51+
// with Status "error" so the caller always gets one entry per plugin.
52+
// Probes run concurrently so one hung plugin doesn't delay the others;
53+
// worst-case duration is one read-timeout, not the sum.
54+
func (p *prober) probeAll(ctx context.Context) []pluginHealthReport {
55+
reports := make([]pluginHealthReport, len(p.plugins))
56+
57+
var wg sync.WaitGroup
58+
for i, plugin := range p.plugins {
59+
wg.Go(func() {
60+
reports[i] = p.probe(ctx, plugin)
61+
})
62+
}
63+
wg.Wait()
64+
65+
return reports
66+
}
67+
68+
func (p *prober) probe(ctx context.Context, plugin pluginClient) pluginHealthReport {
69+
report := pluginHealthReport{
70+
KeyID: plugin.keyID,
71+
LastChecked: p.now(),
72+
}
73+
74+
resp, err := plugin.service.Status(ctx)
75+
switch {
76+
case err != nil:
77+
report.Status = statusError
78+
report.Detail = err.Error()
79+
case resp == nil:
80+
// The in-tree gRPC client never returns (nil, nil), but a misbehaving
81+
// plugin must not panic the reporter.
82+
report.Status = statusError
83+
report.Detail = "kms plugin returned nil status response"
84+
case resp.Healthz == healthzOK:
85+
report.Status = statusHealthy
86+
report.KEKID = resp.KeyID
87+
default:
88+
report.Status = statusUnhealthy
89+
report.Detail = resp.Healthz
90+
}
91+
92+
return report
93+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package health
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"reflect"
7+
"strconv"
8+
"sync"
9+
"testing"
10+
"time"
11+
12+
"k8s.io/apimachinery/pkg/util/wait"
13+
kmsservice "k8s.io/kms/pkg/service"
14+
)
15+
16+
type fakeService struct {
17+
resp *kmsservice.StatusResponse
18+
err error
19+
}
20+
21+
func (f *fakeService) Status(context.Context) (*kmsservice.StatusResponse, error) {
22+
return f.resp, f.err
23+
}
24+
func (f *fakeService) Encrypt(context.Context, string, []byte) (*kmsservice.EncryptResponse, error) {
25+
return nil, nil
26+
}
27+
func (f *fakeService) Decrypt(context.Context, string, *kmsservice.DecryptRequest) ([]byte, error) {
28+
return nil, nil
29+
}
30+
31+
func TestProber_ProbeAll(t *testing.T) {
32+
fixed := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
33+
p := &prober{
34+
plugins: []pluginClient{
35+
{keyID: "1", service: &fakeService{resp: &kmsservice.StatusResponse{Healthz: "ok", KeyID: "kek-abc"}}},
36+
{keyID: "2", service: &fakeService{err: fmt.Errorf("connection refused")}},
37+
{keyID: "3", service: &fakeService{resp: &kmsservice.StatusResponse{Healthz: "degraded"}}},
38+
{keyID: "4", service: &fakeService{}},
39+
},
40+
now: func() time.Time { return fixed },
41+
}
42+
43+
have := p.probeAll(context.Background())
44+
want := []pluginHealthReport{
45+
{KeyID: "1", KEKID: "kek-abc", Status: "healthy", LastChecked: fixed},
46+
{KeyID: "2", Status: "error", Detail: "connection refused", LastChecked: fixed},
47+
{KeyID: "3", Status: "unhealthy", Detail: "degraded", LastChecked: fixed},
48+
{KeyID: "4", Status: "error", Detail: "kms plugin returned nil status response", LastChecked: fixed},
49+
}
50+
if !reflect.DeepEqual(have, want) {
51+
t.Errorf("probeAll():\n have: %+v\n want: %+v", have, want)
52+
}
53+
}
54+
55+
// blockingService releases Status only once all expected probes have
56+
// arrived, so the test passes only if probeAll runs them concurrently.
57+
type blockingService struct {
58+
*fakeService
59+
barrier *sync.WaitGroup
60+
}
61+
62+
func (b *blockingService) Status(ctx context.Context) (*kmsservice.StatusResponse, error) {
63+
b.barrier.Done()
64+
b.barrier.Wait()
65+
return b.fakeService.Status(ctx)
66+
}
67+
68+
func TestProber_ProbeAllFansOut(t *testing.T) {
69+
const n = 3
70+
var barrier sync.WaitGroup
71+
barrier.Add(n)
72+
73+
plugins := make([]pluginClient, 0, n)
74+
for i := range n {
75+
keyID := strconv.Itoa(i + 1)
76+
plugins = append(plugins, pluginClient{
77+
keyID: keyID,
78+
service: &blockingService{
79+
fakeService: &fakeService{resp: &kmsservice.StatusResponse{Healthz: "ok", KeyID: "kek-" + keyID}},
80+
barrier: &barrier,
81+
},
82+
})
83+
}
84+
p := newProber(plugins)
85+
86+
done := make(chan []pluginHealthReport, 1)
87+
go func() { done <- p.probeAll(context.Background()) }()
88+
89+
select {
90+
case have := <-done:
91+
for i, report := range have {
92+
want := strconv.Itoa(i + 1)
93+
if report.KeyID != want || report.KEKID != "kek-"+want {
94+
t.Errorf("reports[%d] = {KeyID:%q KEKID:%q}, want {KeyID:%q KEKID:%q}",
95+
i, report.KeyID, report.KEKID, want, "kek-"+want)
96+
}
97+
}
98+
case <-time.After(wait.ForeverTestTimeout):
99+
t.Fatal("probeAll timed out: probes ran sequentially or deadlocked")
100+
}
101+
}
102+
103+
func Test_keyIDFromSocket(t *testing.T) {
104+
tests := []struct {
105+
socket string
106+
want string
107+
wantErr bool
108+
}{
109+
{socket: "unix:///var/run/kmsplugin/kms-1.sock", want: "1"},
110+
{socket: "unix:///var/run/kmsplugin/kms-42.sock", want: "42"},
111+
{socket: "unix:///var/run/kmsplugin/plugin.sock", wantErr: true},
112+
}
113+
for _, tt := range tests {
114+
t.Run(tt.socket, func(t *testing.T) {
115+
have, err := keyIDFromSocket(tt.socket)
116+
if (err != nil) != tt.wantErr {
117+
t.Fatalf("keyIDFromSocket(%q) err = %v, wantErr %v", tt.socket, err, tt.wantErr)
118+
}
119+
if have != tt.want {
120+
t.Errorf("keyIDFromSocket(%q) = %q, want %q", tt.socket, have, tt.want)
121+
}
122+
})
123+
}
124+
}

0 commit comments

Comments
 (0)