Skip to content
Open
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
55 changes: 35 additions & 20 deletions aks-node-controller/pkg/gen/aksnodeconfig/v1/config.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions aks-node-controller/pkg/nodeconfigutils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const (

AKSNodeConfigFilePath = "/opt/azure/containers/aks-node-controller-config.json"

// EnabledFeaturesFilePath is read by the wrapper; must match its FEATURES_PATH.
EnabledFeaturesFilePath = "/opt/azure/containers/enabled_features.sh"

boothookTemplate = `#cloud-boothook
#!/bin/bash
set -euo pipefail
Expand All @@ -30,7 +33,7 @@ cat <<'EOF' | base64 -d >%[1]s
%[2]s
EOF
chmod 0600 %[1]s

%[3]s
logger -t aks-boothook "launching aks-node-controller service $(date -Ins)"
systemctl start --no-block aks-node-controller.service
`
Expand Down Expand Up @@ -63,7 +66,7 @@ func CustomData(cfg *aksnodeconfigv1.Configuration) (string, error) {
}

encodedAksNodeConfigJSON := base64.StdEncoding.EncodeToString(aksNodeConfigJSON)
boothook := fmt.Sprintf(boothookTemplate, AKSNodeConfigFilePath, encodedAksNodeConfigJSON)
boothook := fmt.Sprintf(boothookTemplate, AKSNodeConfigFilePath, encodedAksNodeConfigJSON, enabledFeaturesBlock(cfg))

var customData bytes.Buffer
writer := multipart.NewWriter(&customData)
Expand Down Expand Up @@ -120,6 +123,19 @@ func writeMIMEPart(writer *multipart.Writer, contentType, content string) error
return err
}

// enabledFeaturesBlock returns the boothook snippet writing the enabled-features file, or ""
// when no feature is on (keeping custom data byte-identical to the default for VHD compat).
func enabledFeaturesBlock(cfg *aksnodeconfigv1.Configuration) string {
if !cfg.GetEnableProvisioningHotfix() {
return ""
}
return fmt.Sprintf(`cat <<'EOF' >%[1]s
ENABLE_PROVISIONING_HOTFIX=true
EOF
chmod 0600 %[1]s
`, EnabledFeaturesFilePath)
}

func MarshalConfigurationV1(cfg *aksnodeconfigv1.Configuration) ([]byte, error) {
options := protojson.MarshalOptions{
UseEnumNumbers: false,
Expand Down
70 changes: 70 additions & 0 deletions aks-node-controller/pkg/nodeconfigutils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,76 @@ func TestCustomDataUsesMultipartBoothookAndCloudConfig(t *testing.T) {
require.ErrorIs(t, err, io.EOF)
}

// decodeBoothook extracts the decoded cloud-boothook part from the base64-encoded custom data.
func decodeBoothook(t *testing.T, cfg *aksnodeconfigv1.Configuration) string {
t.Helper()
customData, err := CustomData(cfg)
require.NoError(t, err)
decoded, err := base64.StdEncoding.DecodeString(customData)
require.NoError(t, err)
sections := strings.SplitN(string(decoded), "\r\n\r\n", 2)
require.Len(t, sections, 2)
message := textproto.MIMEHeader{}
for _, line := range strings.Split(sections[0], "\r\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, ": ", 2)
require.Len(t, parts, 2)
message.Add(parts[0], parts[1])
}
_, params, err := mime.ParseMediaType(message.Get("Content-Type"))
require.NoError(t, err)
reader := multipart.NewReader(strings.NewReader(sections[1]), params["boundary"])
part, err := reader.NextPart()
require.NoError(t, err)
require.Equal(t, "text/cloud-boothook", part.Header.Get("Content-Type"))
boothook, err := io.ReadAll(part)
require.NoError(t, err)
return string(boothook)
}

func TestEnabledFeaturesBlockEmptyWhenDisabled(t *testing.T) {
// The empty return is the load-bearing byte-identity guarantee: with the flag off the
// boothook template's %[3]s placeholder expands to "", so custom data is byte-identical to
// the output produced before this feature existed. This protects the 6-month VHD window.
require.Empty(t, enabledFeaturesBlock(&aksnodeconfigv1.Configuration{}), "expected no features block when EnableProvisioningHotfix is unset/false")
require.Empty(t, enabledFeaturesBlock(&aksnodeconfigv1.Configuration{EnableProvisioningHotfix: false}))
}

func TestCustomDataOmitsEnabledFeaturesWhenHotfixDisabled(t *testing.T) {
// Off-case: no enabled_features.sh write anywhere in the boothook, and no ENABLE_PROVISIONING_HOTFIX.
off := decodeBoothook(t, &aksnodeconfigv1.Configuration{})
require.NotContains(t, off, "enabled_features.sh")
require.NotContains(t, off, "ENABLE_PROVISIONING_HOTFIX")

// Byte-identity: the only difference between disabled and explicitly-false must be nothing.
explicitFalse := decodeBoothook(t, &aksnodeconfigv1.Configuration{EnableProvisioningHotfix: false})
require.Equal(t, off, explicitFalse)
}

func TestCustomDataWritesEnabledFeaturesWhenHotfixEnabled(t *testing.T) {
on := decodeBoothook(t, &aksnodeconfigv1.Configuration{EnableProvisioningHotfix: true})

// Written via a quoted heredoc to the shared feature-flag path, chmod 0600, literal lowercase true.
require.Contains(t, on, "cat <<'EOF' >/opt/azure/containers/enabled_features.sh")
require.Contains(t, on, "\nENABLE_PROVISIONING_HOTFIX=true\n")
require.Contains(t, on, "chmod 0600 /opt/azure/containers/enabled_features.sh")

// The features file must be written BEFORE the service starts, so the wrapper can read it.
featuresIdx := strings.Index(on, "enabled_features.sh")
startIdx := strings.Index(on, "systemctl start --no-block aks-node-controller.service")
require.NotEqual(t, -1, featuresIdx)
require.NotEqual(t, -1, startIdx)
require.Less(t, featuresIdx, startIdx, "enabled_features.sh must be written before the service starts")
}

func TestEnabledFeaturesFilePathMatchesWrapperContract(t *testing.T) {
// Shared contract with the wrapper's FEATURES_PATH default; if it changes here it must
// change there too, or the wrapper will never read the file.
require.Equal(t, "/opt/azure/containers/enabled_features.sh", EnabledFeaturesFilePath)
}

func TestMarshalUnmarshalWithPopulatedConfig(t *testing.T) {
t.Run("fully populated config marshals to >100 bytes", func(t *testing.T) {
cfg := &aksnodeconfigv1.Configuration{}
Expand Down
5 changes: 5 additions & 0 deletions aks-node-controller/proto/aksnodeconfig/v1/config.proto
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,9 @@ message Configuration {

// CSE timeout override in seconds. If not specified, defaults to 15 minutes with a maximum of 360 minutes (6 hours).
optional int32 cse_timeout = 44;

// Enables provisioning-time hotfix checking. When true, aks-node-controller's
// check-hotfix reads the hotfix pointer from the LPS (live-patching-service)
// endpoint; when false it no-ops without any remote call. Default-off, fail-open.
bool enable_provisioning_hotfix = 45;
}
Loading