Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion cmd/operator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,9 @@ func main() {
// Gate TLS profile enforcement on the adherence policy. WMCO was not previously honoring the
// cluster TLS profile, so per the centralized TLS config enhancement (StrictAllComponents mode)
// it should only enforce when ShouldHonorClusterTLSProfile returns true.
honorTLSProfile := libgocrypto.ShouldHonorClusterTLSProfile(tlsAdherence)
var metricsServerTLSOpts []func(*tls.Config)
if libgocrypto.ShouldHonorClusterTLSProfile(tlsAdherence) {
if honorTLSProfile {
tlsConfigFn, unsupportedCiphers := tlspkg.NewTLSConfigFromProfile(tlsProfile)
if len(unsupportedCiphers) > 0 {
setupLog.Info("some cipher suites are not supported by Go and will be ignored",
Expand Down Expand Up @@ -194,6 +195,15 @@ func main() {
setupLog.Error(err, "unable to generate CNI config script")
os.Exit(1)
}
unsupportedWebConfigCiphers, err := payload.PopulateWebConfig(tlsProfile, honorTLSProfile)
if err != nil {
setupLog.Error(err, "unable to generate windows-exporter webconfig")
os.Exit(1)
}
if len(unsupportedWebConfigCiphers) > 0 {
setupLog.Info("some TLS settings are not supported for the windows-exporter webconfig and will be ignored",
"unsupported", unsupportedWebConfigCiphers)
}

// Become the leader before proceeding
err = leader.Become(ctx, "windows-machine-config-operator-lock")
Expand Down
67 changes: 66 additions & 1 deletion controllers/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/openshift/windows-machine-config-operator/pkg/instance"
"github.com/openshift/windows-machine-config-operator/pkg/metadata"
"github.com/openshift/windows-machine-config-operator/pkg/nodeconfig"
"github.com/openshift/windows-machine-config-operator/pkg/nodeconfig/payload"
"github.com/openshift/windows-machine-config-operator/pkg/secrets"
"github.com/openshift/windows-machine-config-operator/version"
)
Expand Down Expand Up @@ -64,11 +65,24 @@ func (r *instanceReconciler) ensureInstanceIsUpToDate(ctx context.Context, insta
return fmt.Errorf("instance cannot be nil")
}

// Instance is up to date, do nothing
// Instance is up to date, do nothing — except check for webconfig changes.
// When the cluster TLS security profile changes, the operator restarts and
// regenerates the webconfig with new TLS settings. Existing nodes already
// have the correct WMCO version annotation, so UpToDate() returns true and
// a full configure/upgrade cycle is skipped. The lightweight webconfig
// update below pushes ONLY the updated webconfig file (via EnsureFile)
// without draining, deconfiguring, or restarting the node — the
// exporter-toolkit's GetConfigForClient reload picks up the new file on
// the next TLS handshake.
if instanceInfo.UpToDate() {
// Instance being up to date indicates that node object is present with the version annotation
r.log.Info("instance is up to date", "node", instanceInfo.Node.GetName(), "version",
instanceInfo.Node.GetAnnotations()[metadata.VersionAnnotation])
// Check if the webconfig needs a lightweight update
if err := r.ensureWebConfigIsUpToDate(ctx, instanceInfo); err != nil {
return fmt.Errorf("error ensuring webconfig is up to date on node %s: %w",
instanceInfo.Node.GetName(), err)
}
return nil
}

Expand Down Expand Up @@ -138,6 +152,57 @@ func (r *instanceReconciler) updateKubeletCA(ctx context.Context, node core.Node
return nodeConfig.UpdateKubeletClientCA(contents)
}

// ensureWebConfigIsUpToDate checks the webconfig SHA annotation on the node and
// pushes the updated webconfig file if it differs from the current payload.
// This is a lightweight update path — it does NOT drain, deconfigure, or
// restart the node. The exporter-toolkit in windows-exporter re-reads the
// webconfig on every TLS handshake, so the new TLS settings take effect on
// the next client connection.
func (r *instanceReconciler) ensureWebConfigIsUpToDate(ctx context.Context, instanceInfo *instance.Info) error {
if instanceInfo.Node == nil {
return nil
}
return r.ensureWebConfigForNode(ctx, *instanceInfo.Node)
}

// ensureWebConfigForNode compares the webconfig SHA annotation on the given
// node with the current payload SHA. If they differ, it pushes the updated
// webconfig and records the new SHA annotation. Returns nil immediately when
// the webconfig is already up to date or when no webconfig SHA is available
// (i.e. PopulateWebConfig has not been called yet).
func (r *instanceReconciler) ensureWebConfigForNode(ctx context.Context, node core.Node) error {
expectedSHA := payload.GetWebConfigSHA()
nodeInfo := &instance.Info{Node: &node}
if nodeInfo.WebConfigUpToDate(expectedSHA) {
return nil
}
r.log.Info("webconfig change detected, pushing updated file",
"node", node.Name, "expectedSHA", expectedSHA)
if err := r.updateWebConfig(ctx, node); err != nil {
return err
}
return metadata.ApplyLabelsAndAnnotations(ctx, r.client, node, nil,
map[string]string{metadata.WebConfigSHAAnnotation: expectedSHA})
}

// updateWebConfig pushes the current webconfig file to the Windows node,
// following the same pattern as updateKubeletCA: create a nodeconfig from the
// node, transfer the file, and close the connection.
func (r *instanceReconciler) updateWebConfig(ctx context.Context, node core.Node) error {
winInstance, err := r.instanceFromNode(ctx, &node)
if err != nil {
return fmt.Errorf("error creating instance for node %s: %w", node.Name, err)
}
nc, err := nodeconfig.NewNodeConfig(ctx, r.client, r.k8sclientset, r.clusterServiceCIDR,
r.watchNamespace, winInstance, r.signer, nil, nil, r.platform)
if err != nil {
return fmt.Errorf("error creating nodeConfig for instance %s: %w", winInstance.Address, err)
}
defer r.nodeConfigCleanup(nc)
r.log.Info("updating webconfig in", "node", node.Name)
return nc.UpdateWebConfig()
}

// GetAddress returns a non-ipv6 address that can be used to reach a Windows node. This can be either an ipv4
// or dns address.
func GetAddress(addresses []core.NodeAddress) (string, error) {
Expand Down
85 changes: 85 additions & 0 deletions controllers/controllers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
core "k8s.io/api/core/v1"
meta "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/openshift/windows-machine-config-operator/pkg/instance"
"github.com/openshift/windows-machine-config-operator/pkg/metadata"
)

func TestGetAddress(t *testing.T) {
Expand Down Expand Up @@ -62,3 +66,84 @@ func TestGetAddress(t *testing.T) {
})
}
}

// TestEnsureWebConfigForNodeDecision verifies the decision logic used by
// ensureWebConfigForNode to determine whether a webconfig push is needed.
// After refactoring, ensureWebConfigForNode delegates this decision to
// instance.Info.WebConfigUpToDate. These table tests exercise the critical
// scenarios the controller must handle correctly.
//
// The full ensureWebConfigForNode path (SSH push + annotation write) requires
// cluster infrastructure and is covered by e2e tests that exercise the
// complete ensureWebConfigForNode → updateWebConfig → nodeconfig pipeline.
func TestEnsureWebConfigForNodeDecision(t *testing.T) {
testCases := []struct {
name string
node *core.Node
expectedSHA string
wantUpToDate bool // true = no push needed (no-op)
}{
{
name: "Missing SHA annotation triggers push",
node: &core.Node{
ObjectMeta: meta.ObjectMeta{
Name: "win-node-1",
Annotations: map[string]string{},
},
},
expectedSHA: "abc123",
wantUpToDate: false,
},
{
name: "Matching SHA annotation is a no-op",
node: &core.Node{
ObjectMeta: meta.ObjectMeta{
Name: "win-node-2",
Annotations: map[string]string{
metadata.WebConfigSHAAnnotation: "abc123",
},
},
},
expectedSHA: "abc123",
wantUpToDate: true,
},
{
name: "Mismatched SHA annotation triggers push",
node: &core.Node{
ObjectMeta: meta.ObjectMeta{
Name: "win-node-3",
Annotations: map[string]string{
metadata.WebConfigSHAAnnotation: "old-sha",
},
},
},
expectedSHA: "new-sha",
wantUpToDate: false,
},
{
name: "Nil node is handled gracefully",
node: nil,
expectedSHA: "abc123",
wantUpToDate: true,
},
{
name: "Empty expected SHA is always up to date",
node: &core.Node{
ObjectMeta: meta.ObjectMeta{
Name: "win-node-4",
Annotations: map[string]string{},
},
},
expectedSHA: "",
wantUpToDate: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
info := &instance.Info{Node: tc.node}
got := info.WebConfigUpToDate(tc.expectedSHA)
assert.Equal(t, tc.wantUpToDate, got)
})
}
}
9 changes: 9 additions & 0 deletions controllers/windowsmachine_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,15 @@ func (r *WindowsMachineReconciler) Reconcile(ctx context.Context,
}
if node.Annotations[metadata.VersionAnnotation] == version.Get() {
// version annotation exists with a valid value, node is fully configured.
// However, the webconfig may need a lightweight update: when the cluster
// TLS security profile changes the operator restarts with a new webconfig,
// but Machine-API nodes with a current version annotation skip
// configureMachine → ensureInstanceIsUpToDate entirely. Check and push
// the updated webconfig here so Machine-API nodes stay in sync.
if err := r.ensureWebConfigForNode(ctx, *node); err != nil {
return ctrl.Result{}, fmt.Errorf("error ensuring webconfig is up to date on node %s: %w",
node.Name, err)
}
return ctrl.Result{}, nil
}
}
Expand Down
12 changes: 12 additions & 0 deletions pkg/instance/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ func (i *Info) UpToDate() bool {
return present && versionAnnotation == version.Get()
}

// WebConfigUpToDate returns true if the instance's webconfig SHA annotation
// matches the given expected SHA. An empty expected SHA is treated as up to
// date (nothing to compare against). A missing annotation on the node is
// treated as outdated so the webconfig will be pushed and the annotation set.
func (i *Info) WebConfigUpToDate(expectedSHA string) bool {
if expectedSHA == "" || i.Node == nil {
return true
}
nodeSHA, present := i.Node.GetAnnotations()[metadata.WebConfigSHAAnnotation]
return present && nodeSHA == expectedSHA
}

// UpgradeRequired returns true if the instance needs to go through the upgrade process
func (i *Info) UpgradeRequired() bool {
// instance being up to date implies instance is fully upgraded
Expand Down
63 changes: 63 additions & 0 deletions pkg/instance/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,69 @@ func TestUpToDate(t *testing.T) {
})
}
}
func TestWebConfigUpToDate(t *testing.T) {
testCases := []struct {
name string
input Info
expectedSHA string
expectedOut bool
}{
{
name: "Empty expected SHA always up to date",
input: Info{Node: &core.Node{}},
expectedSHA: "",
expectedOut: true,
},
{
name: "No associated Node always up to date",
input: Info{Node: nil},
expectedSHA: "abc123",
expectedOut: true,
},
{
name: "Annotation missing treated as outdated",
input: Info{
Node: &core.Node{
ObjectMeta: meta.ObjectMeta{Annotations: map[string]string{}},
},
},
expectedSHA: "abc123",
expectedOut: false,
},
{
name: "Annotation mismatch treated as outdated",
input: Info{
Node: &core.Node{
ObjectMeta: meta.ObjectMeta{Annotations: map[string]string{
metadata.WebConfigSHAAnnotation: "old-sha",
}},
},
},
expectedSHA: "new-sha",
expectedOut: false,
},
{
name: "Annotation matches treated as up to date",
input: Info{
Node: &core.Node{
ObjectMeta: meta.ObjectMeta{Annotations: map[string]string{
metadata.WebConfigSHAAnnotation: "matching-sha",
}},
},
},
expectedSHA: "matching-sha",
expectedOut: true,
},
}

for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
out := test.input.WebConfigUpToDate(test.expectedSHA)
assert.Equal(t, test.expectedOut, out)
})
}
}

func TestUpgradeRequired(t *testing.T) {
testCases := []struct {
name string
Expand Down
5 changes: 5 additions & 0 deletions pkg/metadata/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ const (
RebootAnnotation = "windowsmachineconfig.openshift.io/reboot-required"
// UpgradingLabel indicates the node's underlying instance is performing an upgrade
UpgradingLabel = "windowsmachineconfig.openshift.io/upgrading"
// WebConfigSHAAnnotation tracks the SHA256 of the windows-exporter webconfig
// file on the node. Used by the lightweight webconfig update path to detect
// when the TLS profile has changed and the file needs to be pushed without
// triggering a full deconfigure+configure cycle.
WebConfigSHAAnnotation = "windowsmachineconfig.openshift.io/webconfig-sha"
)

// generatePatch creates a patch applying the given operation onto each given annotation key and value
Expand Down
23 changes: 23 additions & 0 deletions pkg/nodeconfig/nodeconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"github.com/openshift/windows-machine-config-operator/pkg/ignition"
"github.com/openshift/windows-machine-config-operator/pkg/instance"
"github.com/openshift/windows-machine-config-operator/pkg/metadata"
"github.com/openshift/windows-machine-config-operator/pkg/nodeconfig/payload"
"github.com/openshift/windows-machine-config-operator/pkg/nodeutil"
"github.com/openshift/windows-machine-config-operator/pkg/rbac"
"github.com/openshift/windows-machine-config-operator/pkg/registries"
Expand Down Expand Up @@ -196,6 +197,11 @@ func (nc *NodeConfig) Configure(ctx context.Context) error {
// Ensure we are labeling and annotating the node as soon as the Node object is created, so that we can identify
// which controller should be watching it
annotationsToApply := map[string]string{PubKeyHashAnnotation: nc.publicKeyHash}
// Record the webconfig SHA so the lightweight update path can detect
// changes without triggering a full upgrade cycle.
if webConfigSHA := payload.GetWebConfigSHA(); webConfigSHA != "" {
annotationsToApply[metadata.WebConfigSHAAnnotation] = webConfigSHA
}
for key, value := range nc.additionalAnnotations {
annotationsToApply[key] = value
}
Expand Down Expand Up @@ -630,6 +636,23 @@ func (nc *NodeConfig) UpdateKubeletClientCA(contents []byte) error {
return nil
}

// UpdateWebConfig pushes the current webconfig payload file to the Windows
// node. The webconfig file is a compressed archive (.tar.gz) whose SHA is
// already tracked in the payload shaMap, so EnsureFile will skip the transfer
// if the remote file already matches. No service restart is required: the
// exporter-toolkit in windows-exporter re-reads the webconfig on every TLS
// handshake (GetConfigForClient reload).
func (nc *NodeConfig) UpdateWebConfig() error {
webConfigFileInfo, err := payload.NewCompressedFileInfo(payload.TLSConfPath)
if err != nil {
return fmt.Errorf("error creating file info for webconfig: %w", err)
}
if err := nc.Windows.EnsureFile(webConfigFileInfo, windows.TLSDir); err != nil {
return fmt.Errorf("error transferring webconfig to node: %w", err)
}
return nil
}

// SyncTrustedCABundle builds the trusted CA ConfigMap from image registry certificates and the proxy trust bundle
// and ensures the cert bundle on the instance has up-to-date data
func (nc *NodeConfig) SyncTrustedCABundle(ctx context.Context) error {
Expand Down
Loading