Skip to content

Commit 38f7db2

Browse files
committed
kms/health: add report writer
1 parent 445d478 commit 38f7db2

5 files changed

Lines changed: 186 additions & 21 deletions

File tree

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

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"regexp"
77
"time"
88

9-
"github.com/openshift/library-go/pkg/operator/v1helpers"
109
"github.com/spf13/cobra"
1110
"github.com/spf13/pflag"
1211

@@ -25,6 +24,13 @@ const providerName = "kms-health-reporter"
2524
// mounted at, e.g. unix:///var/run/kmsplugin/kms-1.sock.
2625
var kmsSocketPattern = regexp.MustCompile(`^unix:///var/run/kmsplugin/kms-(\d+)\.sock$`)
2726

27+
// NewEncryptionStatusWriterFunc builds the EncryptionStatusWriter for a target
28+
// CR. The caller binds the concrete CR; this package only supplies the rest
29+
// config and a reporterID. reporterID is a stable, per-node identifier the write
30+
// path uses to claim ownership of its own entries; with server-side apply, pass
31+
// it as the field manager.
32+
type NewEncryptionStatusWriterFunc func(restConfig *rest.Config, reporterID string) (EncryptionStatusWriter, error)
33+
2834
// options' flag-bound fields are exported so the struct can be logged as a
2935
// whole via klog.InfoS, which JSON-marshals its values.
3036
type options struct {
@@ -35,21 +41,21 @@ type options struct {
3541
NodeName string
3642
Kubeconfig string
3743

38-
newOperatorClient func(*rest.Config) (v1helpers.OperatorClient, error)
44+
newWriter NewEncryptionStatusWriterFunc
3945
}
4046

4147
type Config struct {
42-
operatorClient v1helpers.OperatorClient
43-
prober *prober
48+
writeStatus EncryptionStatusWriter
49+
prober *prober
4450

4551
interval time.Duration
4652
writeTimeout time.Duration
4753
nodeName string
4854
}
4955

50-
func NewCommand(ctx context.Context, newOperatorClient func(*rest.Config) (v1helpers.OperatorClient, error)) *cobra.Command {
56+
func NewCommand(ctx context.Context, newWriter NewEncryptionStatusWriterFunc) *cobra.Command {
5157
o := &options{
52-
newOperatorClient: newOperatorClient,
58+
newWriter: newWriter,
5359
}
5460

5561
cmd := &cobra.Command{
@@ -126,9 +132,12 @@ func (o *options) Config(ctx context.Context) (*Config, error) {
126132
return nil, fmt.Errorf("build rest config: %w", err)
127133
}
128134

129-
operatorClient, err := o.newOperatorClient(restCfg)
135+
// reporterID is the per-node ownership identity. The naming convention lives
136+
// here, not in the caller's writer, so all three operators stay uniform.
137+
reporterID := "kms-health-reporter-" + o.NodeName
138+
writeStatus, err := o.newWriter(restCfg, reporterID)
130139
if err != nil {
131-
return nil, fmt.Errorf("build operator client: %w", err)
140+
return nil, fmt.Errorf("build encryption status writer: %w", err)
132141
}
133142

134143
plugins, err := buildPlugins(ctx, o.KMSSockets, o.ReadTimeout)
@@ -137,21 +146,25 @@ func (o *options) Config(ctx context.Context) (*Config, error) {
137146
}
138147

139148
return &Config{
140-
operatorClient: operatorClient,
141-
prober: newProber(plugins),
142-
interval: o.Interval,
143-
writeTimeout: o.WriteTimeout,
144-
nodeName: o.NodeName,
149+
writeStatus: writeStatus,
150+
prober: newProber(plugins),
151+
interval: o.Interval,
152+
writeTimeout: o.WriteTimeout,
153+
nodeName: o.NodeName,
145154
}, nil
146155
}
147156

148157
func (c *Config) Run(ctx context.Context) error {
149158
wait.JitterUntilWithContext(ctx, func(ctx context.Context) {
150159
// Each Status RPC enforces the read timeout internally (set at dial
151160
// time); ctx here only carries shutdown cancellation.
152-
conditions := c.prober.probeAll(ctx)
153-
// TODO: hand conditions to the writer once it lands; logging is a placeholder.
154-
klog.InfoS("kms plugin health", "conditions", conditions)
161+
reports := c.prober.probeAll(ctx)
162+
163+
writeCtx, cancel := context.WithTimeout(ctx, c.writeTimeout)
164+
defer cancel()
165+
if err := c.writeStatus(writeCtx, buildEncryptionStatus(c.nodeName, reports)); err != nil {
166+
klog.ErrorS(err, "failed to publish kms plugin health")
167+
}
155168
}, c.interval, 0.1, false)
156169

157170
return nil

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package health
22

33
import (
4+
"context"
45
"testing"
56
"time"
67

78
"github.com/stretchr/testify/require"
9+
10+
applyoperatorv1 "github.com/openshift/client-go/operator/applyconfigurations/operator/v1"
11+
kmsservice "k8s.io/kms/pkg/service"
812
)
913

1014
// validOptions returns an options value that passes validate. Each test case
@@ -119,3 +123,33 @@ func TestValidate(t *testing.T) {
119123
})
120124
}
121125
}
126+
127+
// TestRunReportsOnce checks the loop wiring: Run probes, builds the status, and
128+
// hands it to the reporter. The reporter cancels the context so the loop ends
129+
// after a single tick.
130+
func TestRunReportsOnce(t *testing.T) {
131+
ctx, cancel := context.WithCancel(context.Background())
132+
133+
var have *applyoperatorv1.KMSEncryptionStatusApplyConfiguration
134+
c := &Config{
135+
nodeName: "node-1",
136+
interval: time.Hour, // never reached; cancelled after the first tick
137+
writeTimeout: time.Second,
138+
prober: &prober{
139+
plugins: []pluginClient{
140+
{keyID: "1", service: &fakeService{resp: &kmsservice.StatusResponse{Healthz: "ok", KeyID: "kek-abc"}}},
141+
},
142+
now: func() time.Time { return time.Unix(0, 0).UTC() },
143+
},
144+
writeStatus: func(_ context.Context, status *applyoperatorv1.KMSEncryptionStatusApplyConfiguration) error {
145+
have = status
146+
cancel()
147+
return nil
148+
},
149+
}
150+
151+
require.NoError(t, c.Run(ctx))
152+
require.Len(t, have.HealthReports, 1)
153+
require.Equal(t, "node-1", *have.HealthReports[0].NodeName)
154+
require.Equal(t, "1", *have.HealthReports[0].KeyId)
155+
}

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ const (
2121
type pluginHealthReport struct {
2222
// KeyID is the controller's sequential key id; KEKID is the KMS provider's
2323
// encryption key id. Distinct identifiers, easy to confuse.
24-
KeyID string
25-
KEKID string
26-
Status string
27-
LastChecked time.Time
28-
Detail string
24+
KeyID string `json:"keyID"`
25+
KEKID string `json:"kekID,omitempty"`
26+
Status string `json:"status"`
27+
LastChecked time.Time `json:"lastChecked"`
28+
Detail string `json:"detail,omitempty"`
2929
}
3030

3131
// pluginClient is the dialed handle to one co-located KMS plugin; the plugin
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package health
2+
3+
import (
4+
"context"
5+
6+
operatorv1 "github.com/openshift/api/operator/v1"
7+
applyoperatorv1 "github.com/openshift/client-go/operator/applyconfigurations/operator/v1"
8+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9+
)
10+
11+
// EncryptionStatusWriter is injected by the operator. The operator knows where
12+
// to put it. Creating the status is the health reporter's concern.
13+
type EncryptionStatusWriter func(ctx context.Context, status *applyoperatorv1.KMSEncryptionStatusApplyConfiguration) error
14+
15+
// buildEncryptionStatus builds the KMSEncryptionStatusApplyConfiguration to be
16+
// applied by the operator.
17+
func buildEncryptionStatus(nodeName string, reports []pluginHealthReport) *applyoperatorv1.KMSEncryptionStatusApplyConfiguration {
18+
healthReports := make([]*applyoperatorv1.KMSPluginHealthReportApplyConfiguration, 0, len(reports))
19+
for _, r := range reports {
20+
hr := applyoperatorv1.KMSPluginHealthReport().
21+
WithNodeName(nodeName).
22+
WithKeyId(r.KeyID).
23+
WithStatus(mapStatus(r.Status)).
24+
WithLastCheckedTime(metav1.NewTime(r.LastChecked))
25+
26+
// kekId/detail have MinLength=1; setting "" would fail validation.
27+
if r.KEKID != "" {
28+
hr = hr.WithKEKId(r.KEKID)
29+
}
30+
if r.Detail != "" {
31+
hr = hr.WithDetail(r.Detail)
32+
}
33+
34+
healthReports = append(healthReports, hr)
35+
}
36+
37+
return applyoperatorv1.KMSEncryptionStatus().WithHealthReports(healthReports...)
38+
}
39+
40+
// mapStatus defaults to Error so an unknown value never becomes an empty,
41+
// invalid enum.
42+
func mapStatus(s string) operatorv1.KMSPluginHealthStatus {
43+
switch s {
44+
case statusHealthy:
45+
return operatorv1.KMSPluginHealthStatusHealthy
46+
case statusUnhealthy:
47+
return operatorv1.KMSPluginHealthStatusUnhealthy
48+
default:
49+
return operatorv1.KMSPluginHealthStatusError
50+
}
51+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package health
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
operatorv1 "github.com/openshift/api/operator/v1"
10+
applyoperatorv1 "github.com/openshift/client-go/operator/applyconfigurations/operator/v1"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
)
13+
14+
func TestBuildEncryptionStatus(t *testing.T) {
15+
// Fixed UTC time dodges Go's monotonic clock and timezone drift.
16+
checked := time.Unix(0, 0).UTC()
17+
reports := []pluginHealthReport{
18+
{KeyID: "1", KEKID: "kek-abc", Status: statusHealthy, LastChecked: checked},
19+
{KeyID: "2", Status: statusUnhealthy, Detail: "not ok", LastChecked: checked},
20+
{KeyID: "3", Status: statusError, Detail: "DeadlineExceeded", LastChecked: checked},
21+
}
22+
23+
have := buildEncryptionStatus("node-1", reports)
24+
25+
// Each entry stamps nodeName; kekId only on healthy, detail only on the
26+
// unhealthy/error entries, status mapped to the API enum.
27+
want := applyoperatorv1.KMSEncryptionStatus().WithHealthReports(
28+
applyoperatorv1.KMSPluginHealthReport().
29+
WithNodeName("node-1").
30+
WithKeyId("1").
31+
WithStatus(operatorv1.KMSPluginHealthStatusHealthy).
32+
WithLastCheckedTime(metav1.NewTime(checked)).
33+
WithKEKId("kek-abc"),
34+
applyoperatorv1.KMSPluginHealthReport().
35+
WithNodeName("node-1").
36+
WithKeyId("2").
37+
WithStatus(operatorv1.KMSPluginHealthStatusUnhealthy).
38+
WithLastCheckedTime(metav1.NewTime(checked)).
39+
WithDetail("not ok"),
40+
applyoperatorv1.KMSPluginHealthReport().
41+
WithNodeName("node-1").
42+
WithKeyId("3").
43+
WithStatus(operatorv1.KMSPluginHealthStatusError).
44+
WithLastCheckedTime(metav1.NewTime(checked)).
45+
WithDetail("DeadlineExceeded"),
46+
)
47+
48+
require.Equal(t, want, have)
49+
}
50+
51+
func TestMapStatus(t *testing.T) {
52+
tests := []struct {
53+
in string
54+
want operatorv1.KMSPluginHealthStatus
55+
}{
56+
{statusHealthy, operatorv1.KMSPluginHealthStatusHealthy},
57+
{statusUnhealthy, operatorv1.KMSPluginHealthStatusUnhealthy},
58+
{statusError, operatorv1.KMSPluginHealthStatusError},
59+
{"unexpected", operatorv1.KMSPluginHealthStatusError},
60+
}
61+
62+
for _, tc := range tests {
63+
t.Run(tc.in, func(t *testing.T) {
64+
require.Equal(t, tc.want, mapStatus(tc.in))
65+
})
66+
}
67+
}

0 commit comments

Comments
 (0)