From aa520a0c2aa789fa8634bd1c960da4bc377a045e Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Fri, 24 Apr 2026 15:54:16 -0700 Subject: [PATCH 1/4] feat: add GH Action for ANC hotfix template injection Add a GitHub Action workflow that auto-injects the ANC hotfix version into nodecustomdata.yml when hack/anc-hotfix-version.json is updated in a PR targeting an official/* release branch. Files added: - .github/workflows/anc-hotfix-generate.yml: workflow with same infra pattern as hotfix-generate.yml (Azure login, App token, commit via API) - hack/anc_hotfix_generate.py: reads version file, validates YYYYMM.DD.PATCH format, idempotently injects write_files entry in scriptless section - hack/anc-hotfix-version.json: empty by default, operator sets version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/anc-hotfix-generate.yml | 75 ++++++++++++ hack/anc-hotfix-version.json | 1 + hack/anc_hotfix_generate.py | 139 ++++++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 .github/workflows/anc-hotfix-generate.yml create mode 100644 hack/anc-hotfix-version.json create mode 100644 hack/anc_hotfix_generate.py diff --git a/.github/workflows/anc-hotfix-generate.yml b/.github/workflows/anc-hotfix-generate.yml new file mode 100644 index 00000000000..24c56f91afd --- /dev/null +++ b/.github/workflows/anc-hotfix-generate.yml @@ -0,0 +1,75 @@ +name: ANC Hotfix Template Update +# Injects the ANC hotfix version into nodecustomdata.yml when +# hack/anc-hotfix-version.json is updated in a PR targeting an official/* branch. + +on: + pull_request: + branches: + - 'official/**' + paths: + - 'hack/anc-hotfix-version.json' + types: [opened, synchronize, reopened] + +permissions: + id-token: write + contents: read + pull-requests: read + +jobs: + anc-hotfix-generate: + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + environment: test + steps: + - name: Azure login + uses: azure/login@v3 + with: + client-id: ${{ secrets.AZURE_KV_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_KV_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_KV_SUBSCRIPTION_ID }} + + - name: Retrieve App private key + uses: azure/cli@v2 + id: app-private-key + with: + azcliversion: latest + inlineScript: | + private_key=$(az keyvault secret show --vault-name ${{ secrets.AZURE_KV_NAME }} -n ${{ secrets.APP_PRIVATE_KEY_SECRET_NAME }} --query value -o tsv | sed 's/$/\\n/g' | tr -d '\n' | head -c -2) &> /dev/null + echo "::add-mask::$private_key" + echo "private-key=$private_key" >> $GITHUB_OUTPUT + + - name: Generate App token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ steps.app-private-key.outputs.private-key }} + repositories: AgentBaker + + - name: Checkout PR branch + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Inject ANC hotfix version into template + run: python3 hack/anc_hotfix_generate.py + + - name: Commit changes via API + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + FILE="parts/linux/cloud-init/nodecustomdata.yml" + if git diff --quiet "$FILE"; then + echo "No template changes needed." + exit 0 + fi + CONTENT=$(base64 -w 0 "$FILE") + SHA=$(gh api "repos/${{ github.repository }}/contents/${FILE}?ref=${{ github.head_ref }}" --jq '.sha') + gh api "repos/${{ github.repository }}/contents/${FILE}" \ + -X PUT \ + -f message="chore: auto-inject ANC hotfix version into template" \ + -f content="$CONTENT" \ + -f branch="${{ github.head_ref }}" \ + -f sha="$SHA" diff --git a/hack/anc-hotfix-version.json b/hack/anc-hotfix-version.json new file mode 100644 index 00000000000..9e26dfeeb6e --- /dev/null +++ b/hack/anc-hotfix-version.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/hack/anc_hotfix_generate.py b/hack/anc_hotfix_generate.py new file mode 100644 index 00000000000..d9542cd1057 --- /dev/null +++ b/hack/anc_hotfix_generate.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Reads the ANC hotfix version from hack/anc-hotfix-version.json and injects +(or updates) the aks-node-controller-hotfix.json write_files entry into the +EnableScriptlessCSECmd section of nodecustomdata.yml. + +Usage: python3 hack/anc_hotfix_generate.py + +This script is called by the anc-hotfix-generate GH Action. +""" + +import json +import re +import sys + +TEMPLATE = "parts/linux/cloud-init/nodecustomdata.yml" +VERSION_FILE = "hack/anc-hotfix-version.json" +HOTFIX_PATH = "/opt/azure/containers/aks-node-controller-hotfix.json" + +# Marker comments for idempotent injection +BEGIN_MARKER = "# ---- anc-hotfix: auto-generated ----" +END_MARKER = "# ---- end anc-hotfix ----" + + +def read_hotfix_version(): + """Read and validate the hotfix version from the version file.""" + try: + with open(VERSION_FILE) as f: + data = json.load(f) + except FileNotFoundError: + print(f"{VERSION_FILE} not found. Nothing to do.") + return None + + version = data.get("version", "").strip() + if not version: + print(f"{VERSION_FILE} has no version set. Nothing to do.") + return None + + # Validate YYYYMM.DD.PATCH format + if not re.match(r'^\d{6}\.\d{1,2}\.\d+$', version): + print(f"ERROR: invalid version format '{version}', " + f"expected YYYYMM.DD.PATCH (e.g., 202604.01.1)", file=sys.stderr) + sys.exit(1) + + return version + + +def build_hotfix_entry(version): + """Build the write_files YAML lines for the hotfix JSON config.""" + hotfix_json = json.dumps({"version": version}) + return [ + f"\n", + f"{BEGIN_MARKER}\n", + f"- path: {HOTFIX_PATH}\n", + f" permissions: \"0644\"\n", + f" owner: root\n", + f" content: |\n", + f" {hotfix_json}\n", + f"{END_MARKER}\n", + ] + + +def inject(version): + """Inject or update the ANC hotfix entry in the scriptless section of nodecustomdata.yml.""" + with open(TEMPLATE) as f: + content = f.read() + + # Remove any previous ANC hotfix entry (idempotent) + content = re.sub( + rf'\n?{re.escape(BEGIN_MARKER)}\n.*?{re.escape(END_MARKER)}\n', + '', content, flags=re.DOTALL, + ) + + lines = content.splitlines(keepends=True) + + # Find the EnableScriptlessCSECmd block and its {{- else}} boundary + scriptless_start = None + else_idx = None + for i, line in enumerate(lines): + if '{{if EnableScriptlessCSECmd}}' in line: + scriptless_start = i + if scriptless_start is not None and else_idx is None: + if line.strip().startswith('{{- else'): + else_idx = i + + if scriptless_start is None or else_idx is None: + print("ERROR: could not find EnableScriptlessCSECmd / {{- else}} boundary " + "in template", file=sys.stderr) + sys.exit(1) + + print(f"Template structure:", file=sys.stderr) + print(f" EnableScriptlessCSECmd: line {scriptless_start + 1}", file=sys.stderr) + print(f" {{{{- else}}}}: line {else_idx + 1}", file=sys.stderr) + + entry_lines = build_hotfix_entry(version) + + # Insert just before the {{- else}} line + final = lines[:else_idx] + entry_lines + lines[else_idx:] + + with open(TEMPLATE, 'w') as f: + f.writelines(final) + + print(f"Injected ANC hotfix version {version} into {TEMPLATE}", file=sys.stderr) + return True + + +def remove_hotfix(): + """Remove any existing ANC hotfix entry from the template.""" + with open(TEMPLATE) as f: + content = f.read() + + new_content = re.sub( + rf'\n?{re.escape(BEGIN_MARKER)}\n.*?{re.escape(END_MARKER)}\n', + '', content, flags=re.DOTALL, + ) + + if new_content != content: + with open(TEMPLATE, 'w') as f: + f.write(new_content) + print(f"Removed previous ANC hotfix entry from {TEMPLATE}", file=sys.stderr) + return True + return False + + +def main(): + version = read_hotfix_version() + if version: + inject(version) + print(f"\nDone. Injected ANC hotfix version {version}.") + else: + # No version set — remove any stale hotfix entry + if remove_hotfix(): + print("\nDone. Removed stale ANC hotfix entry.") + else: + print("\nNothing to do.") + + +if __name__ == '__main__': + main() From 7eaf56e6600f553c330406dac70fd3e2804ff6b6 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Fri, 24 Apr 2026 16:04:43 -0700 Subject: [PATCH 2/4] feat: add GH Action for ANC hotfix template injection Add a GitHub Action workflow that auto-injects the ANC hotfix version into nodecustomdata.yml when hotfix/anc-hotfix-version.json is updated in a PR targeting an official/* release branch. Files added: - .github/workflows/anc-hotfix-generate.yml: workflow with same infra pattern as hotfix-generate.yml (Azure login, App token, commit via API) - hotfix/anc_hotfix_generate.py: reads version file, validates YYYYMM.DD.PATCH format, idempotently injects write_files entry in scriptless section - hotfix/anc-hotfix-version.json: empty by default, operator sets version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/anc-hotfix-generate.yml | 4 ++-- AGENTS.md | 1 + {hack => hotfix}/anc-hotfix-version.json | 0 {hack => hotfix}/anc_hotfix_generate.py | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) rename {hack => hotfix}/anc-hotfix-version.json (100%) rename {hack => hotfix}/anc_hotfix_generate.py (97%) diff --git a/.github/workflows/anc-hotfix-generate.yml b/.github/workflows/anc-hotfix-generate.yml index 24c56f91afd..c1cb38eeb19 100644 --- a/.github/workflows/anc-hotfix-generate.yml +++ b/.github/workflows/anc-hotfix-generate.yml @@ -7,7 +7,7 @@ on: branches: - 'official/**' paths: - - 'hack/anc-hotfix-version.json' + - 'hotfix/anc-hotfix-version.json' types: [opened, synchronize, reopened] permissions: @@ -54,7 +54,7 @@ jobs: token: ${{ steps.app-token.outputs.token }} - name: Inject ANC hotfix version into template - run: python3 hack/anc_hotfix_generate.py + run: python3 hotfix/anc_hotfix_generate.py - name: Commit changes via API env: diff --git a/AGENTS.md b/AGENTS.md index 4ba259e37d4..4040498fa55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,7 @@ Analyze PRs for these compatibility scenarios: - Only allowed runtime downloads: packages.aks.azure.com or other explicitly allowed sources in CSE - **Function signature changes**: Parameters, return values, exit codes that break callers - **Missing test coverage**: Changes to provisioning logic without corresponding e2e tests + - **ANC hotfix entry removal**: If a PR removes or modifies the `anc-hotfix: auto-generated` block in `parts/linux/cloud-init/nodecustomdata.yml` or resets `hotfix/anc-hotfix-version.json` to `{}`, **always confirm with the PR owner** that all affected VHDs have been republished with the fix baked in or are out of the 6-month support window. Premature removal means nodes provisioned via scale-up on the old buggy VHD will no longer receive the hotfix. **2. Windows Bidirectional Compatibility** - **Context**: Windows VHD and CSE scripts release on different cadences with no guaranteed order diff --git a/hack/anc-hotfix-version.json b/hotfix/anc-hotfix-version.json similarity index 100% rename from hack/anc-hotfix-version.json rename to hotfix/anc-hotfix-version.json diff --git a/hack/anc_hotfix_generate.py b/hotfix/anc_hotfix_generate.py similarity index 97% rename from hack/anc_hotfix_generate.py rename to hotfix/anc_hotfix_generate.py index d9542cd1057..d76bb3bfb85 100644 --- a/hack/anc_hotfix_generate.py +++ b/hotfix/anc_hotfix_generate.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Reads the ANC hotfix version from hack/anc-hotfix-version.json and injects +Reads the ANC hotfix version from hotfix/anc-hotfix-version.json and injects (or updates) the aks-node-controller-hotfix.json write_files entry into the EnableScriptlessCSECmd section of nodecustomdata.yml. @@ -14,7 +14,7 @@ import sys TEMPLATE = "parts/linux/cloud-init/nodecustomdata.yml" -VERSION_FILE = "hack/anc-hotfix-version.json" +VERSION_FILE = "hotfix/anc-hotfix-version.json" HOTFIX_PATH = "/opt/azure/containers/aks-node-controller-hotfix.json" # Marker comments for idempotent injection From 6936818bbaa5a77527f602adbf8d04e6bd35d208 Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Fri, 24 Apr 2026 16:15:00 -0700 Subject: [PATCH 3/4] feat: add anc-hotfix label trigger for manual workflow invocation Adds pull_request_target trigger so adding the 'anc-hotfix' label to any PR will run the ANC hotfix template injection workflow. Uses same pattern as scripts hotfix workflow with 'hotfix' label. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/anc-hotfix-generate.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/anc-hotfix-generate.yml b/.github/workflows/anc-hotfix-generate.yml index c1cb38eeb19..b9439143714 100644 --- a/.github/workflows/anc-hotfix-generate.yml +++ b/.github/workflows/anc-hotfix-generate.yml @@ -1,6 +1,10 @@ name: ANC Hotfix Template Update # Injects the ANC hotfix version into nodecustomdata.yml when -# hack/anc-hotfix-version.json is updated in a PR targeting an official/* branch. +# hotfix/anc-hotfix-version.json is updated in a PR targeting an official/* branch. +# +# Triggers: +# 1. Automatically when a PR targets an official/* release branch +# 2. Manually when an "anc-hotfix" label is added to any PR on: pull_request: @@ -9,6 +13,8 @@ on: paths: - 'hotfix/anc-hotfix-version.json' types: [opened, synchronize, reopened] + pull_request_target: + types: [labeled] permissions: id-token: write @@ -17,7 +23,13 @@ permissions: jobs: anc-hotfix-generate: - if: github.event.pull_request.head.repo.full_name == github.repository + # Run if: PR targets official/* branch, OR "anc-hotfix" label was just added (same-repo PRs only) + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + ( + (github.event_name == 'pull_request' && startsWith(github.base_ref, 'official/')) || + (github.event_name == 'pull_request_target' && github.event.label.name == 'anc-hotfix') + ) runs-on: ubuntu-latest environment: test steps: From ca3a9abe84b8b96c92c474fd802b56f9198ad73c Mon Sep 17 00:00:00 2001 From: Devin Wong Date: Fri, 24 Apr 2026 16:32:38 -0700 Subject: [PATCH 4/4] fix: address PR review comments on anc_hotfix_generate.py - Fix usage string path (hack/ -> hotfix/) - Add json.JSONDecodeError handling for invalid JSON - Tighten day regex to require exactly 2 digits (DD) - Remove debug prints that add CI log noise Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- hotfix/anc_hotfix_generate.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/hotfix/anc_hotfix_generate.py b/hotfix/anc_hotfix_generate.py index d76bb3bfb85..e9525269019 100644 --- a/hotfix/anc_hotfix_generate.py +++ b/hotfix/anc_hotfix_generate.py @@ -4,7 +4,7 @@ (or updates) the aks-node-controller-hotfix.json write_files entry into the EnableScriptlessCSECmd section of nodecustomdata.yml. -Usage: python3 hack/anc_hotfix_generate.py +Usage: python3 hotfix/anc_hotfix_generate.py This script is called by the anc-hotfix-generate GH Action. """ @@ -30,6 +30,9 @@ def read_hotfix_version(): except FileNotFoundError: print(f"{VERSION_FILE} not found. Nothing to do.") return None + except json.JSONDecodeError as e: + print(f"ERROR: {VERSION_FILE} contains invalid JSON: {e}", file=sys.stderr) + sys.exit(1) version = data.get("version", "").strip() if not version: @@ -37,7 +40,7 @@ def read_hotfix_version(): return None # Validate YYYYMM.DD.PATCH format - if not re.match(r'^\d{6}\.\d{1,2}\.\d+$', version): + if not re.match(r'^\d{6}\.\d{2}\.\d+$', version): print(f"ERROR: invalid version format '{version}', " f"expected YYYYMM.DD.PATCH (e.g., 202604.01.1)", file=sys.stderr) sys.exit(1) @@ -88,10 +91,6 @@ def inject(version): "in template", file=sys.stderr) sys.exit(1) - print(f"Template structure:", file=sys.stderr) - print(f" EnableScriptlessCSECmd: line {scriptless_start + 1}", file=sys.stderr) - print(f" {{{{- else}}}}: line {else_idx + 1}", file=sys.stderr) - entry_lines = build_hotfix_entry(version) # Insert just before the {{- else}} line