diff --git a/.github/workflows/hotfix-generate.yml b/.github/workflows/hotfix-generate.yml index 40174f95cef..14eb9c616d7 100644 --- a/.github/workflows/hotfix-generate.yml +++ b/.github/workflows/hotfix-generate.yml @@ -78,6 +78,18 @@ jobs: run: | python3 hotfix/hotfix_generate.py "origin/${{ github.base_ref }}" + # Runs AFTER hotfix injection (so the //go:embed'd nodecustomdata.yml carries the injected + # scripts H) and BEFORE the commit step (so an oversized hotfix fails the PR before its + # content is committed). + # Guard 1 (TestCustomDataSizeWithHotfix): slim Mode B CustomData (P + H) < 87,380. + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version: '1.25' + - name: Validate CustomData size + run: | + go test ./pkg/agent/ -run TestCustomDataSizeWithHotfix -v -count=1 + - name: Commit changes via API env: GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/pkg/agent/customdata_size_guard_test.go b/pkg/agent/customdata_size_guard_test.go new file mode 100644 index 00000000000..195cdc07108 --- /dev/null +++ b/pkg/agent/customdata_size_guard_test.go @@ -0,0 +1,105 @@ +package agent + +import ( + "testing" + + "github.com/Azure/agentbaker/pkg/agent/datamodel" +) + +// CustomData size guard for the scriptless NBC CSE path. +// +// Background: +// +// In Mode B (slim CustomData), the generated payload carries P (platform content) + H (hotfix- +// injected scripts). R (customer certs) is moved to protectedSettings and is bounded by RP-side +// per-field cert byte-size validation, so only H can overflow CustomData. +// +// This guard asserts that the slim Mode B CustomData stays under MaxCustomDataLength (87,380). +// In the hotfix CI it runs AFTER hotfix_generate.py injects the real H into nodecustomdata.yml, +// so the //go:embed'd template already carries H — no synthetic injection needed. + +// newScriptlessGuardConfig builds a minimal scriptless NBC config for the given distro. +func newScriptlessGuardConfig(distro datamodel.Distro) *datamodel.NodeBootstrappingConfiguration { + agentPoolProfile := &datamodel.AgentPoolProfile{ + Name: "nodepool1", + OSType: datamodel.Linux, + Distro: distro, + } + return &datamodel.NodeBootstrappingConfiguration{ + ContainerService: &datamodel.ContainerService{ + Location: "eastus", + Properties: &datamodel.Properties{ + OrchestratorProfile: &datamodel.OrchestratorProfile{ + OrchestratorVersion: "1.29.0", + OrchestratorType: datamodel.Kubernetes, + KubernetesConfig: &datamodel.KubernetesConfig{ + ContainerRuntimeConfig: map[string]string{}, + }, + }, + HostedMasterProfile: &datamodel.HostedMasterProfile{ + FQDN: "test-cluster.hcp.eastus.azmk8s.io", + }, + AgentPoolProfiles: []*datamodel.AgentPoolProfile{agentPoolProfile}, + }, + }, + AgentPoolProfile: agentPoolProfile, + CloudSpecConfig: datamodel.AzurePublicCloudSpecForTest, + K8sComponents: &datamodel.K8sComponents{}, + KubeletConfig: map[string]string{}, + EnableScriptlessNBCCSECmd: true, + // The hotfix flow sets ENABLE_PROVISIONING_HOTFIX=true, which adds enabled_features.sh + // to the Mode B payload via renderEnabledFeatures. Include it so the guard measures + // the same payload size as a real hotfix-enabled node. + EnabledFeatures: map[string]string{"ENABLE_PROVISIONING_HOTFIX": "true"}, + } +} + +// TestCustomDataSizeWithHotfix (Guard 1): the slim Mode B CustomData (base platform content P +// plus any hotfix-injected scripts H already embedded in nodecustomdata.yml) must stay under +// MaxCustomDataLength. Forcing ScriptlessCSEProvisionMode makes getScriptlessBoothook return the +// slim CustomData directly, so this measures exactly the Mode B CustomData branch. +// +// The template has distro-conditional blocks (Ubuntu vs Mariner/AzureLinux vs Flatcar vs OSGuard) +// and a cloud-conditional block (IsAKSCustomCloud inlines init-aks-cloud.sh), so we table-drive +// representative distros × cloud combinations to ensure no rendering branch exceeds the limit. +func TestCustomDataSizeWithHotfix(t *testing.T) { + cases := []struct { + name string + distro datamodel.Distro + customCloud bool + }{ + {"Ubuntu2204", datamodel.AKSUbuntuContainerd2204Gen2, false}, + {"Ubuntu2204_CustomCloud", datamodel.AKSUbuntuContainerd2204Gen2, true}, + {"AzureLinuxV2", datamodel.AKSAzureLinuxV2Gen2, false}, + {"AzureLinuxV3", datamodel.AKSAzureLinuxV3Gen2, false}, + {"AzureLinuxV3OSGuard", datamodel.AKSAzureLinuxV3OSGuardGen2FIPSTL, false}, + {"Flatcar", datamodel.AKSFlatcarGen2, false}, + {"ACL", datamodel.AKSACLGen2TL, false}, + } + + templateGenerator := InitializeTemplateGenerator() + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + config := newScriptlessGuardConfig(tc.distro) + if tc.customCloud { + config.ContainerService.Properties.CustomCloudEnv = &datamodel.CustomCloudEnv{ + Name: "akscustom", + } + } + // Force Mode B so getScriptlessBoothook early-returns the slim CustomData (P + H). + config.ScriptlessCSEProvisionMode = true + + payload := templateGenerator.getScriptlessBoothook(config) + + if len(payload) >= MaxCustomDataLength { + t.Fatalf("slim Mode B CustomData (%s) is %d bytes, must be < MaxCustomDataLength (%d). "+ + "A hotfix injecting too many/too-large scripts (H) overflowed the CustomData limit; "+ + "there is no further fallback once in Mode B, so the node would fail to provision.", + tc.name, len(payload), MaxCustomDataLength) + } + t.Logf("slim Mode B CustomData (%s): %d / %d bytes (%d headroom)", + tc.name, len(payload), MaxCustomDataLength, MaxCustomDataLength-len(payload)) + }) + } +}