From 76b6bd7e365aa8f9a985e5afb63e46e475be3584 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Tue, 20 Oct 2026 16:10:35 -0700 Subject: [PATCH 1/4] fix: deduplicate existing scriptless hotfix entries Compare complete canonical write_files blocks before hotfix injection and remove stale generated duplicates without changing distinct entries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f611d25-fb17-4857-86d5-ae9f0746edad --- hotfix/hotfix_generate.py | 31 +++++++---- hotfix/hotfix_generate_test.py | 94 ++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/hotfix/hotfix_generate.py b/hotfix/hotfix_generate.py index 2623604bac3..432c96eb0ae 100644 --- a/hotfix/hotfix_generate.py +++ b/hotfix/hotfix_generate.py @@ -12,10 +12,10 @@ 2. Detects which CSE provisioning scripts differ from the immutable VHD baseline (the release tag the VHD was built from, derived from linux_sig_version.json), selects their write_files entries from parts/linux/cloud-init/nodecustomdata.yml, - injects those entries into the scriptless section of nodecustomdata.yml, and - renders self-contained ANC payloads for Ubuntu and Mariner with AgentBaker's - canonical Go-template renderer. Diffing against the frozen baseline keeps both - delivery paths cumulative. + injects entries not already supplied by the scriptless section into + nodecustomdata.yml, and renders self-contained ANC payloads for Ubuntu and + Mariner with AgentBaker's canonical Go-template renderer. Diffing against the + frozen baseline keeps both delivery paths cumulative. 3. Writes `scripts_version` for script hotfixes. With --use-anc-for-scripts, also writes the `hotfixes` map to activate the embedded ANC payload. Independent ANC @@ -467,7 +467,7 @@ def update_nodecustomdata(target_varkeys): return lines = clean_content.splitlines(keepends=True) - _, else_line, end_line = find_block_boundaries(lines) + start_line, else_line, end_line = find_block_boundaries(lines) if else_line is None or end_line is None: raise GenerationError( f"could not find traditional write_files section in {TEMPLATE}" @@ -480,10 +480,23 @@ def update_nodecustomdata(target_varkeys): if not selected_blocks: raise GenerationError("no matching write_files blocks found for nodecustomdata") - injected_lines = ["\n", f"{SCRIPTS_BEGIN}\n"] - for block_lines in selected_blocks: - injected_lines.extend(block_lines) - injected_lines.append(f"{SCRIPTS_END}\n") + # Compare entire blocks, including guards and content, ignoring only boundary + # whitespace. Matching paths or variable keys alone can hide distinct entries. + existing_blocks = { + "".join(block_lines).strip() + for _, block_lines in parse_write_files_blocks(lines[start_line + 1:else_line]) + } + selected_blocks = [ + block_lines for block_lines in selected_blocks + if "".join(block_lines).strip() not in existing_blocks + ] + + injected_lines = [] + if selected_blocks: + injected_lines = ["\n", f"{SCRIPTS_BEGIN}\n"] + for block_lines in selected_blocks: + injected_lines.extend(block_lines) + injected_lines.append(f"{SCRIPTS_END}\n") with open(TEMPLATE, "w") as template_file: template_file.writelines(lines[:else_line] + injected_lines + lines[else_line:]) diff --git a/hotfix/hotfix_generate_test.py b/hotfix/hotfix_generate_test.py index 23b5ab5b11e..3afae8998e8 100644 --- a/hotfix/hotfix_generate_test.py +++ b/hotfix/hotfix_generate_test.py @@ -377,6 +377,100 @@ def test_update_nodecustomdata_injects_and_removes_selected_blocks(self): self.assertNotIn(hotfix_generate.SCRIPTS_BEGIN, cleaned) self.assertEqual(1, cleaned.count("provisionSourceUbuntu")) + def test_update_nodecustomdata_deduplicates_existing_scriptless_block(self): + repository = Path(__file__).resolve().parents[1] + original = (repository / hotfix_generate.TEMPLATE).read_text() + lines = original.splitlines(keepends=True) + start, outer_else, end = hotfix_generate.find_block_boundaries(lines) + init_block = next( + "".join(block).strip() + "\n" + for keys, block in hotfix_generate.parse_write_files_blocks( + lines[outer_else + 1:end] + ) + if keys == {"initAKSCloud"} + ) + self.assertIn(init_block, "".join(lines[start + 1:outer_else])) + + for selected in ({"initAKSCloud"}, {"initAKSCloud", "provisionSource"}): + with self.subTest(selected=selected), tempfile.TemporaryDirectory() as temp_dir: + template = Path(temp_dir) / "nodecustomdata.yml" + template.write_text( + "".join(lines[:outer_else]) + + f"\n{hotfix_generate.SCRIPTS_BEGIN}\n" + + init_block + + f"{hotfix_generate.SCRIPTS_END}\n" + + "".join(lines[outer_else:]) + ) + with mock.patch.object(hotfix_generate, "TEMPLATE", str(template)): + hotfix_generate.update_nodecustomdata(selected) + updated = template.read_text() + updated_lines = updated.splitlines(keepends=True) + _, updated_else, updated_end = hotfix_generate.find_block_boundaries( + updated_lines + ) + self.assertEqual( + lines[outer_else + 1:end], + updated_lines[updated_else + 1:updated_end], + ) + self.assertEqual(2, updated.count(init_block.strip())) + if "provisionSource" in selected: + generated = updated.split(hotfix_generate.SCRIPTS_BEGIN, 1)[1] + generated = generated.split(hotfix_generate.SCRIPTS_END, 1)[0] + self.assertNotIn("initAKSCloud", generated) + self.assertIn('"provisionSource"', generated) + else: + self.assertNotIn(hotfix_generate.SCRIPTS_BEGIN, updated) + + hotfix_generate.update_nodecustomdata(selected) + self.assertEqual(updated, template.read_text()) + + hotfix_generate.update_nodecustomdata(set()) + self.assertEqual(original, template.read_text()) + hotfix_generate.update_nodecustomdata(set()) + self.assertEqual(original, template.read_text()) + + def test_update_nodecustomdata_keeps_differently_defined_blocks(self): + block = ( + "{{if IsAKSCustomCloud}}\n" + "- path: {{GetInitAKSCloudFilepath}}\n" + ' permissions: "0744"\n' + " encoding: gzip\n" + " owner: root\n" + " content: !!binary |\n" + ' {{GetVariableProperty "cloudInitData" "initAKSCloud"}}\n' + "{{end}}\n" + ) + variants = ( + block.replace("IsAKSCustomCloud", "IsMariner"), + "{{if IsMariner}}\n" + block + "{{end}}\n", + block.replace("{{if IsAKSCustomCloud}}\n", "").removesuffix("{{end}}\n"), + block.replace('"0744"', '"0644"'), + block.replace("owner: root", "owner: other"), + block.replace("encoding: gzip", "encoding: b64"), + block.replace("GetInitAKSCloudFilepath", "GetCSEHelpersScriptFilepath"), + block.replace('"initAKSCloud"', '"provisionSource"'), + ) + for existing in variants: + with self.subTest(existing=existing), tempfile.TemporaryDirectory() as temp_dir: + template = Path(temp_dir) / "nodecustomdata.yml" + template.write_text( + "#cloud-config\nwrite_files:\n" + "{{if EnableScriptlessCSECmd}}\n" + + existing + + "{{- else }}\n" + + block + + "{{- end }}\n" + ) + with mock.patch.object(hotfix_generate, "TEMPLATE", str(template)): + hotfix_generate.update_nodecustomdata({"initAKSCloud"}) + updated = template.read_text() + generated = updated.split(hotfix_generate.SCRIPTS_BEGIN, 1)[1] + generated = generated.split(hotfix_generate.SCRIPTS_END, 1)[0] + self.assertEqual(block.strip(), generated.strip()) + self.assertIn(existing, updated) + hotfix_generate.update_nodecustomdata({"initAKSCloud"}) + self.assertEqual(updated, template.read_text()) + def test_write_hotfix_file_uses_hotfixes_and_preserves_scripts_version(self): with tempfile.TemporaryDirectory() as temp_dir: target = Path(temp_dir) / "hotfix.json" From 618cb37fd795c8f84aab9c8cdc7a80fddb60bc3c Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Wed, 21 Oct 2026 11:44:09 -0700 Subject: [PATCH 2/4] fix: include selected cloud initialization hotfix scripts Enable the custom-cloud rendering guard for selected hotfix blocks without restricting the shared VHD to custom clouds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f611d25-fb17-4857-86d5-ae9f0746edad --- hotfix/render-nodecustomdata/main.go | 5 +- hotfix/render-nodecustomdata/main_test.go | 69 +++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 hotfix/render-nodecustomdata/main_test.go diff --git a/hotfix/render-nodecustomdata/main.go b/hotfix/render-nodecustomdata/main.go index 798f8d36875..6a7e03ee286 100644 --- a/hotfix/render-nodecustomdata/main.go +++ b/hotfix/render-nodecustomdata/main.go @@ -61,7 +61,7 @@ func main() { } // newRenderConfig builds the minimal NodeBootstrappingConfiguration required to -// run agent.RenderLinuxNodeCustomDataTemplate. Only the Distro selector affects +// run agent.RenderLinuxNodeCustomDataTemplate. Distro and CustomCloudEnv affect // the hotfix write_files blocks we render; the remaining fields (orchestrator // version, location, FQDN, KubernetesConfig, etc.) are placeholders whose sole // purpose is to satisfy the renderer's unconditional dereferences of @@ -77,6 +77,9 @@ func newRenderConfig(distro datamodel.Distro) *datamodel.NodeBootstrappingConfig ContainerService: &datamodel.ContainerService{ Location: "eastus", Properties: &datamodel.Properties{ + // Include changed custom-cloud scripts selected by the generator. + // This rendering flag does not make the VHD custom-cloud-only. + CustomCloudEnv: &datamodel.CustomCloudEnv{Name: "akscustom"}, OrchestratorProfile: &datamodel.OrchestratorProfile{ OrchestratorVersion: "1.29.0", OrchestratorType: datamodel.Kubernetes, diff --git a/hotfix/render-nodecustomdata/main_test.go b/hotfix/render-nodecustomdata/main_test.go new file mode 100644 index 00000000000..d490001e6c4 --- /dev/null +++ b/hotfix/render-nodecustomdata/main_test.go @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +package main + +import ( + "fmt" + "strings" + "testing" + + "github.com/Azure/agentbaker/pkg/agent" + "github.com/Azure/agentbaker/pkg/agent/datamodel" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestRenderSelectedHotfixScripts(t *testing.T) { + const initCloudBlock = `{{if IsAKSCustomCloud}} +- path: {{GetInitAKSCloudFilepath}} + permissions: "0744" + encoding: gzip + owner: root + content: !!binary | + {{GetVariableProperty "cloudInitData" "initAKSCloud"}} +{{end}} +` + const helperBlock = `- path: {{GetCSEHelpersScriptFilepath}} + permissions: "0744" + encoding: gzip + owner: root + content: !!binary | + {{GetVariableProperty "cloudInitData" "provisionSource"}} +` + for _, distro := range []datamodel.Distro{ + datamodel.AKSUbuntuContainerd2204Gen2, + datamodel.AKSAzureLinuxV3Gen2, + } { + for _, selected := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/initCloudSelected=%t", distro, selected), func(t *testing.T) { + template := "#cloud-config\nwrite_files:\n" + helperBlock + if selected { + template += initCloudBlock + } + rendered, err := agent.RenderLinuxNodeCustomDataTemplate( + []byte(template), newRenderConfig(distro), + ) + require.NoError(t, err) + var payload struct { + WriteFiles []struct { + Path string `yaml:"path"` + Content string `yaml:"content"` + } `yaml:"write_files"` + } + require.NoError(t, yaml.Unmarshal([]byte(rendered), &payload)) + require.NotEmpty(t, payload.WriteFiles) + require.Equal(t, 1, strings.Count(rendered, "- path: /opt/azure/containers/provision_source.sh")) + wantInitCount := 0 + if selected { + wantInitCount = 1 + } + require.Equal(t, wantInitCount, strings.Count(rendered, "- path: /opt/azure/containers/init-aks-cloud.sh")) + require.Len(t, payload.WriteFiles, 1+wantInitCount) + for _, file := range payload.WriteFiles { + require.NotEmpty(t, file.Content) + } + }) + } + } +} From 12ed9fa82d8f8ff428049b96c6b10029cae4f91e Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Wed, 21 Oct 2026 11:46:40 -0700 Subject: [PATCH 3/4] docs: clarify hotfix rendering flag scope Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f611d25-fb17-4857-86d5-ae9f0746edad --- hotfix/render-nodecustomdata/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hotfix/render-nodecustomdata/main.go b/hotfix/render-nodecustomdata/main.go index 6a7e03ee286..efbc87235f4 100644 --- a/hotfix/render-nodecustomdata/main.go +++ b/hotfix/render-nodecustomdata/main.go @@ -78,7 +78,7 @@ func newRenderConfig(distro datamodel.Distro) *datamodel.NodeBootstrappingConfig Location: "eastus", Properties: &datamodel.Properties{ // Include changed custom-cloud scripts selected by the generator. - // This rendering flag does not make the VHD custom-cloud-only. + // This rendering flag does not make the scripts for custom-cloud only. CustomCloudEnv: &datamodel.CustomCloudEnv{Name: "akscustom"}, OrchestratorProfile: &datamodel.OrchestratorProfile{ OrchestratorVersion: "1.29.0", From 836ce1313523dfc9691918290909aec34454d4a7 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Wed, 21 Oct 2026 11:48:02 -0700 Subject: [PATCH 4/4] test: normalize existing generated hotfix fixture Clean a temporary template copy before capturing the handwritten baseline so cleanup assertions also work on regenerated release branches. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9f611d25-fb17-4857-86d5-ae9f0746edad --- hotfix/hotfix_generate_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hotfix/hotfix_generate_test.py b/hotfix/hotfix_generate_test.py index 3afae8998e8..8095b2c6042 100644 --- a/hotfix/hotfix_generate_test.py +++ b/hotfix/hotfix_generate_test.py @@ -380,6 +380,12 @@ def test_update_nodecustomdata_injects_and_removes_selected_blocks(self): def test_update_nodecustomdata_deduplicates_existing_scriptless_block(self): repository = Path(__file__).resolve().parents[1] original = (repository / hotfix_generate.TEMPLATE).read_text() + with tempfile.TemporaryDirectory() as temp_dir: + template = Path(temp_dir) / "nodecustomdata.yml" + template.write_text(original) + with mock.patch.object(hotfix_generate, "TEMPLATE", str(template)): + hotfix_generate.update_nodecustomdata(set()) + original = template.read_text() lines = original.splitlines(keepends=True) start, outer_else, end = hotfix_generate.find_block_boundaries(lines) init_block = next(