Skip to content
Merged
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
6 changes: 3 additions & 3 deletions aks-node-controller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ sequenceDiagram
ARM->>VM: Deploy config.json<br/>(CustomData)
note over VM: cloud-init handles<br/>config.json deployment

note over VM: cloud-init completes processing
note over VM: Start aks-node-controller.service (systemd service)<br/>after cloud-init
note over VM: cloud-boothook writes config.json early
note over VM: cloud-boothook starts aks-node-controller.service<br/>once config is on disk
VM->>VM: Run aks-node-controller<br/>(Go binary) in provision mode<br/>using config.json

ARM->>VM: Initiate aks-node-controller (Go binary)<br/>in provision-wait mode via CSE
Expand All @@ -137,7 +137,7 @@ sequenceDiagram

Key components:

1. `aks-node-controller.service`: systemd unit that is triggered once cloud-init is complete (guaranteeing that config is present on disk) and then kickstarts bootstrapping.
1. `aks-node-controller.service`: systemd unit that can be started directly by cloud-boothook as soon as the config file is written, while remaining enabled on the VHD as a fallback boot hook.
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README states aks-node-controller.service remains enabled on the VHD as a fallback boot hook, but this PR’s unit file change removes the [Install] section (so systemctl enable aks-node-controller.service fails during VHD build). Either update this doc to match the new enable/start model, or restore an enable-able unit definition so the fallback claim is accurate.

Suggested change
1. `aks-node-controller.service`: systemd unit that can be started directly by cloud-boothook as soon as the config file is written, while remaining enabled on the VHD as a fallback boot hook.
1. `aks-node-controller.service`: systemd unit that is started directly by cloud-boothook as soon as the config file is written; it is started explicitly by the provisioning flow rather than being persistently enabled on the VHD as a fallback boot hook.

Copilot uses AI. Check for mistakes.
2. `aks-node-controller` go binary with two modes:

- **provision**: Parses the node configuration and starts the bootstrap sequence.
Expand Down
1 change: 1 addition & 0 deletions aks-node-controller/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ func getCSEEnv(config *aksnodeconfigv1.Configuration) map[string]string {
"SERVICE_ACCOUNT_IMAGE_PULL_DEFAULT_TENANT_ID": config.GetServiceAccountImagePullProfile().GetDefaultTenantId(),
"IDENTITY_BINDINGS_LOCAL_AUTHORITY_SNI": config.GetServiceAccountImagePullProfile().GetLocalAuthoritySni(),
"CSE_TIMEOUT": getCSETimeout(config),
"SKIP_WAAGENT_HOLD": "true",
}

for i, cert := range config.CustomCaCerts {
Expand Down
17 changes: 0 additions & 17 deletions aks-node-controller/parser/templates/cse_cmd.sh.gtpl
Original file line number Diff line number Diff line change
@@ -1,21 +1,4 @@
echo $(date),$(hostname) > ${PROVISION_OUTPUT};
{{if not .GetDisableCustomData}}
CLOUD_INIT_STATUS_SCRIPT="/opt/azure/containers/cloud-init-status-check.sh";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the effort in this PR is somewhat remove the hard dependency with cloud-init status ready. However, the cloud-init-status-check.sh was added by a repair item for some intermittent sev2. Not meaning we can't remove it, just need to be aware that it could cause intermittent sev2.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we run the service before even cloud init is finished, so waiting for it doesnt make sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may be add this part of provision-wait?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aks-node-controller doesn't depend on it. The status check is more for the provisioning scripts cse_*.sh, which was what I saw from that sev2.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sync'd offline. Nishchay checked with the original owner of cloud-init-status-check.sh and confirmed this is no longer needed.

cloudInitExitCode=0;
if [ -f "${CLOUD_INIT_STATUS_SCRIPT}" ]; then
/bin/bash -c "source ${CLOUD_INIT_STATUS_SCRIPT}; handleCloudInitStatus \"${PROVISION_OUTPUT}\"; returnStatus=\$?; echo \"Cloud init status check exit code: \$returnStatus\" >> ${PROVISION_OUTPUT}; exit \$returnStatus" >> ${PROVISION_OUTPUT} 2>&1;
else
cloud-init status --wait > /dev/null 2>&1;
fi;
cloudInitExitCode=$?;
if [ "$cloudInitExitCode" -eq 0 ]; then
echo "cloud-init succeeded" >> ${PROVISION_OUTPUT};
else
echo "cloud-init failed with exit code ${cloudInitExitCode}" >> ${PROVISION_OUTPUT};
cat ${PROVISION_OUTPUT}
exit ${cloudInitExitCode};
fi;
{{end}}
{{if getIsAksCustomCloud .CustomCloudConfig}}
REPO_DEPOT_ENDPOINT="{{.CustomCloudConfig.RepoDepotEndpoint}}"
{{getInitAKSCustomCloudFilepath}} >> /var/log/azure/cluster-provision.log 2>&1;
Comment on lines 1 to 4
Expand Down
103 changes: 95 additions & 8 deletions aks-node-controller/pkg/nodeconfigutils/utils.go
Original file line number Diff line number Diff line change
@@ -1,34 +1,121 @@
package nodeconfigutils

import (
"bytes"
"encoding/base64"
"fmt"
"mime/multipart"
"net/textproto"

aksnodeconfigv1 "github.com/Azure/agentbaker/aks-node-controller/pkg/gen/aksnodeconfig/v1"
"google.golang.org/protobuf/encoding/protojson"
)

const (
cloudConfigTemplate = `#cloud-config
write_files:
- path: /opt/azure/containers/aks-node-controller-config.json
permissions: "0755"
owner: root
content: !!binary |
%s`
CSE = "/opt/azure/containers/aks-node-controller provision-wait"

boothookTemplate = `#cloud-boothook
#!/bin/bash
set -euo pipefail

logger -t aks-boothook "boothook start $(date -Ins)"

mkdir -p /opt/azure/containers

cat <<'EOF' | base64 -d >/opt/azure/containers/aks-node-controller-config.json
%s
EOF
chmod 0644 /opt/azure/containers/aks-node-controller-config.json

logger -t aks-boothook "launching aks-node-controller service $(date -Ins)"
systemctl start --no-block aks-node-controller.service
`

cloudConfigTemplate = `#cloud-config
runcmd:
- echo "AKS Node Controller cloud-init completed at $(date)"
`

flatcarTemplate = `{
"ignition": { "version": "3.4.0" },
"storage": {
"files": [{
"path": "/opt/azure/containers/aks-node-controller-config.json",
"mode": 420,
"contents": { "source": "data:;base64,%s" }
}]
}
}`
)

// CustomData builds a base64-encoded MIME multipart document to be used as VM custom data for cloud-init.
// It encodes the node configuration as JSON, embeds it in a cloud-boothook script that writes the config
// to disk and starts the aks-node-controller service, then pairs it with a cloud-config part. Cloud-init
// processes each MIME part according to its Content-Type during the VM's first boot.
func CustomData(cfg *aksnodeconfigv1.Configuration) (string, error) {
aksNodeConfigJSON, err := MarshalConfigurationV1(cfg)
if err != nil {
return "", fmt.Errorf("failed to marshal nbc, error: %w", err)
}

encodedAksNodeConfigJSON := base64.StdEncoding.EncodeToString(aksNodeConfigJSON)
customDataYAML := fmt.Sprintf(cloudConfigTemplate, encodedAksNodeConfigJSON)
boothook := fmt.Sprintf(boothookTemplate, encodedAksNodeConfigJSON)

var customData bytes.Buffer
writer := multipart.NewWriter(&customData)

fmt.Fprintf(&customData, "MIME-Version: 1.0\r\n")
fmt.Fprintf(&customData, "Content-Type: multipart/mixed; boundary=%q\r\n\r\n", writer.Boundary())
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: what is the reason we need it to be multipart MIME?
IIUC, the first part is the cloud-boothook which tries to write the aks-node-config to the node asap. Is the second part only to print a message currently? Are we going to use this second part for other purpose in the future?

Copy link
Contributor Author

@awesomenix awesomenix Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idea is to bundle our current nodecustomdata.yaml probably for hotfixing doesnt work since hotfixing needs to use boothook as well.


if err := writeMIMEPart(writer, "text/cloud-boothook", boothook); err != nil {
return "", fmt.Errorf("failed to write boothook part: %w", err)
}
if err := writeMIMEPart(writer, "text/cloud-config", cloudConfigTemplate); err != nil {
return "", fmt.Errorf("failed to write cloud-config part: %w", err)
}
if err := writer.Close(); err != nil {
return "", fmt.Errorf("failed to finalize multipart custom data: %w", err)
}

return base64.StdEncoding.EncodeToString(customData.Bytes()), nil
}

// CustomDataFlatcar builds base64-encoded custom data for Flatcar Container Linux nodes.
// Unlike Ubuntu/Azure Linux which use cloud-init and expect MIME multipart custom data,
// Flatcar uses Ignition (configured via Butane) to process machine configuration. Ignition
// consumes a JSON document that declaratively specifies files to write to disk, so we embed
// the node config directly as a base64 data URI in an Ignition storage entry instead of
// wrapping it in a MIME multipart boothook script.
func CustomDataFlatcar(cfg *aksnodeconfigv1.Configuration) (string, error) {
aksNodeConfigJSON, err := MarshalConfigurationV1(cfg)
if err != nil {
return "", fmt.Errorf("failed to marshal nbc, error: %w", err)
}

encodedAksNodeConfigJSON := base64.StdEncoding.EncodeToString(aksNodeConfigJSON)
customDataYAML := fmt.Sprintf(flatcarTemplate, encodedAksNodeConfigJSON)
return base64.StdEncoding.EncodeToString([]byte(customDataYAML)), nil
}

// writeMIMEPart writes a single part to a MIME multipart message. Cloud-init expects custom data
// as a MIME multipart document where each part carries a Content-Type that tells cloud-init how to
// process it (e.g. "text/cloud-boothook" for early-boot scripts, "text/cloud-config" for declarative
// cloud-config YAML). This helper creates one such part with the appropriate headers.
func writeMIMEPart(writer *multipart.Writer, contentType, content string) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does MIME stand for? can we had a comment explaining

header := textproto.MIMEHeader{}
header.Set("Content-Type", contentType)
header.Set("MIME-Version", "1.0")
header.Set("Content-Transfer-Encoding", "7bit")

part, err := writer.CreatePart(header)
if err != nil {
return err
}

_, err = part.Write([]byte(content))
return err
}

func MarshalConfigurationV1(cfg *aksnodeconfigv1.Configuration) ([]byte, error) {
options := protojson.MarshalOptions{
UseEnumNumbers: false,
Expand Down
67 changes: 67 additions & 0 deletions aks-node-controller/pkg/nodeconfigutils/utils_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package nodeconfigutils

import (
"encoding/base64"
"io"
"mime"
"mime/multipart"
"net/textproto"
"os"
"strings"
"testing"

aksnodeconfigv1 "github.com/Azure/agentbaker/aks-node-controller/pkg/gen/aksnodeconfig/v1"
Expand Down Expand Up @@ -204,6 +210,67 @@ func TestMarsalConfiguratioV1(t *testing.T) {
require.JSONEq(t, `{"version":"v1","auth_config":{"subscription_id":"test-subscription"}, "workload_runtime":"WORKLOAD_RUNTIME_OCI_CONTAINER"}`, string(data))
}

func TestCustomDataUsesMultipartBoothookAndCloudConfig(t *testing.T) {
cfg := &aksnodeconfigv1.Configuration{
Version: "v1",
AuthConfig: &aksnodeconfigv1.AuthConfig{
SubscriptionId: "test-subscription",
},
ClusterConfig: &aksnodeconfigv1.ClusterConfig{
ResourceGroup: "test-rg",
Location: "eastus",
},
ApiServerConfig: &aksnodeconfigv1.ApiServerConfig{
ApiServerName: "test-api-server",
},
}

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])
}
mediaType, params, err := mime.ParseMediaType(message.Get("Content-Type"))
require.NoError(t, err)
require.Equal(t, "multipart/mixed", mediaType)

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)
require.True(t, strings.HasPrefix(string(boothook), "#cloud-boothook\n"))
require.Contains(t, string(boothook), "/opt/azure/containers/aks-node-controller-config.json")
require.Contains(t, string(boothook), "launching aks-node-controller service")
require.Contains(t, string(boothook), "systemctl start --no-block aks-node-controller.service")

part, err = reader.NextPart()
require.NoError(t, err)
require.Equal(t, "text/cloud-config", part.Header.Get("Content-Type"))
cloudConfig, err := io.ReadAll(part)
require.NoError(t, err)
require.True(t, strings.HasPrefix(string(cloudConfig), "#cloud-config\n"))
require.Contains(t, string(cloudConfig), "runcmd:")

_, err = reader.NextPart()
require.ErrorIs(t, err, io.EOF)
}

func TestMarshalUnmarshalWithPopulatedConfig(t *testing.T) {
t.Run("fully populated config marshals to >100 bytes", func(t *testing.T) {
cfg := &aksnodeconfigv1.Configuration{}
Expand Down
2 changes: 1 addition & 1 deletion e2e/node_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ func nbcToAKSNodeConfigV1(nbc *datamodel.NodeBootstrappingConfiguration) *aksnod
return &aksnodeconfigv1.Configuration{
Version: "v1",
BootstrappingConfig: bootstrappingConfig,
DisableCustomData: nbc.AgentPoolProfile.IsFlatcar() || nbc.AgentPoolProfile.IsACL(),
DisableCustomData: true,
LinuxAdminUsername: "azureuser",
VmSize: config.Config.DefaultVMSKU,
ClusterConfig: &aksnodeconfigv1.ClusterConfig{
Expand Down
73 changes: 56 additions & 17 deletions e2e/vmss.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,25 +81,58 @@ func ConfigureAndCreateVMSS(ctx context.Context, s *Scenario) (*ScenarioVM, erro
return vm, err
}

// CustomDataWithHack is similar to nodeconfigutils.CustomData, but it uses a hack to run new aks-node-controller binary
// CustomDataWithHack is similar to nodeconfigutils.CustomData, but it uses a hack to run new aks-node-controller binary.
// Original aks-node-controller isn't run because it fails systemd check validating aks-node-controller-config.json exists
// check aks-node-controller.service for details
// a new binary is downloaded from the given URL and run with provision command
// (check aks-node-controller.service for details).
//
// Uses a cloud-boothook to write the config file and create a systemd service unit early in boot (during cloud-init init).
// The systemd service waits for network-online.target before downloading the binary and running provisioning,
// avoiding the race condition where runcmd or boothook scripts execute before networking is available.
// Flatcar cannot use boothooks (coreos-cloudinit doesn't support MIME multipart), so it uses cloud-config
// with a coreos.units block to define and start the service instead.
func CustomDataWithHack(s *Scenario, binaryURL string) (string, error) {
cloudConfigTemplate := `#cloud-config
write_files:
- path: /opt/azure/containers/aks-node-controller-config-hack.json
permissions: "0755"
owner: root
content: !!binary |
%s
runcmd:
- mkdir -p /opt/azure/bin
- curl -fSL "%s" -o /opt/azure/bin/aks-node-controller-hack
- chmod +x /opt/azure/bin/aks-node-controller-hack
- /opt/azure/bin/aks-node-controller-hack provision --provision-config=/opt/azure/containers/aks-node-controller-config-hack.json &
cloudConfigTemplate := `#cloud-boothook
#!/bin/bash
set -euo pipefail

mkdir -p /opt/azure/containers /opt/azure/bin

cat <<'EOF' | base64 -d > /opt/azure/containers/aks-node-controller-config-hack.json
%s
EOF
chmod 0755 /opt/azure/containers/aks-node-controller-config-hack.json

cat <<'SCRIPT' > /opt/azure/bin/run-aks-node-controller-hack.sh
#!/bin/bash
set -euo pipefail
mkdir -p /opt/azure/bin
curl -fSL --retry 10 --retry-delay 2 "%s" -o /opt/azure/bin/aks-node-controller-hack
chmod +x /opt/azure/bin/aks-node-controller-hack
/opt/azure/bin/aks-node-controller-hack provision --provision-config=/opt/azure/containers/aks-node-controller-config-hack.json
SCRIPT
chmod +x /opt/azure/bin/run-aks-node-controller-hack.sh

cat <<'UNIT' > /etc/systemd/system/aks-node-controller-hack.service
[Unit]
Description=Downloads and runs the AKS node controller hack
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/azure/bin/run-aks-node-controller-hack.sh

[Install]
WantedBy=basic.target
UNIT

systemctl daemon-reload
systemctl start --no-block aks-node-controller-hack.service
`
Comment on lines +129 to 131
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The boothook creates and enables aks-node-controller-hack.service but never starts it. systemctl enable does not start the unit, and depending on when the boothook runs relative to basic.target, the unit may not run on the first boot at all. To mirror the main boothook flow and make this deterministic, start the unit explicitly (e.g., systemctl start --no-block ...) or use systemctl enable --now ....

Suggested change
systemctl daemon-reload
systemctl enable aks-node-controller-hack.service
`
systemctl daemon-reload
systemctl enable --now aks-node-controller-hack.service
`

Copilot uses AI. Check for mistakes.
if s.VHD.Flatcar {
// Flatcar uses coreos-cloudinit which only supports a subset of cloud-config features
// and does not handle MIME multipart or boothooks. Use coreos.units to define the service instead.
// https://github.com/flatcar/coreos-cloudinit/blob/main/Documentation/cloud-config.md#coreos-parameters
cloudConfigTemplate = `#cloud-config
write_files:
- path: /opt/azure/containers/aks-node-controller-config-hack.json
Expand All @@ -114,7 +147,7 @@ write_files:
#!/bin/bash
set -euo pipefail
mkdir -p /opt/azure/bin
curl -fSL "%s" -o /opt/azure/bin/aks-node-controller-hack
curl -fSL --retry 10 --retry-delay 2 "%s" -o /opt/azure/bin/aks-node-controller-hack
chmod +x /opt/azure/bin/aks-node-controller-hack
/opt/azure/bin/aks-node-controller-hack provision --provision-config=/opt/azure/containers/aks-node-controller-config-hack.json
# Flatcar specific configuration. It supports only a subset of cloud-init features https://github.com/flatcar/coreos-cloudinit/blob/main/Documentation/cloud-config.md#coreos-parameters
Expand Down Expand Up @@ -154,7 +187,13 @@ func createVMSSModel(ctx context.Context, s *Scenario) armcompute.VirtualMachine
cse = nodeconfigutils.CSE
customData = func() string {
if config.Config.DisableScriptLessCompilation {
data, err := nodeconfigutils.CustomData(s.Runtime.AKSNodeConfig)
var data string
var err error
if s.VHD.Flatcar {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add a comment explaining why different for flatcar?

data, err = nodeconfigutils.CustomDataFlatcar(s.Runtime.AKSNodeConfig)
} else {
data, err = nodeconfigutils.CustomData(s.Runtime.AKSNodeConfig)
}
require.NoError(s.T, err, "failed to generate custom data from AKSNodeConfig")
return data
}
Expand Down
10 changes: 4 additions & 6 deletions parts/linux/cloud-init/artifacts/aks-node-controller.service
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
[Unit]
Description=Parse contract and run csecmd
ConditionPathExists=/opt/azure/containers/aks-node-controller-config.json
After=cloud-init.target
After=oem-cloudinit.service enable-oem-cloudinit.service
Wants=cloud-init.target
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/azure/containers/aks-node-controller-wrapper.sh
RemainAfterExit=No
RemainAfterExit=yes

[Install]
WantedBy=cloud-init.target
WantedBy=oem-cloudinit.service
WantedBy=basic.target
Comment on lines 3 to +13
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switching the unit to WantedBy=basic.target while keeping ConditionPathExists=/opt/azure/containers/aks-node-controller-config.json can prevent the service from ever running on flows where the config is only written later by cloud-init (e.g., older/custom data that uses write_files). systemd will skip the unit when the condition fails at basic.target time and won’t automatically retry when the file appears. Consider keeping the enable/fallback path tied to cloud-init.target (as before) and rely on the boothook’s explicit systemctl start for early-start, or add a path/trigger that starts the service when the config file is created.

Copilot uses AI. Check for mistakes.
Loading
Loading