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
87 changes: 87 additions & 0 deletions .github/workflows/anc-hotfix-generate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: ANC Hotfix Template Update
# Injects the ANC hotfix version into nodecustomdata.yml when
# 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:
branches:
- 'official/**'
paths:
- 'hotfix/anc-hotfix-version.json'
types: [opened, synchronize, reopened]
pull_request_target:
types: [labeled]

permissions:
id-token: write
contents: read
pull-requests: read

jobs:
anc-hotfix-generate:
# 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:
- 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 hotfix/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"
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions hotfix/anc-hotfix-version.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
138 changes: 138 additions & 0 deletions hotfix/anc_hotfix_generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
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.

Usage: python3 hotfix/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 = "hotfix/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
Comment thread
Devinwong marked this conversation as resolved.
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:
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{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)

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()
Loading