Skip to content

Commit e1542bd

Browse files
authored
test: Add forward and backward compatibility tests to aks-node-controller config file (#7349)
1 parent ec8df4e commit e1542bd

File tree

4 files changed

+433
-20
lines changed

4 files changed

+433
-20
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package nodeconfigutils
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"strings"
8+
"sync"
9+
"testing"
10+
11+
aksnodeconfigv1 "github.com/Azure/agentbaker/aks-node-controller/pkg/gen/aksnodeconfig/v1"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
const (
16+
// Commit from 2025-11-08 when the first version of Config was released.
17+
// CAREFUL: Test failure may indicate backward compatibility breakage.
18+
// This commit should be 6-8 months old to account for VHD release cycles and deployment timelines.
19+
// Investigate thoroughly before updating.
20+
backwardCompatCommit = "c4ddd55f7dd72bfb541313fe586f586dfa7bd114"
21+
22+
// Commit when PopulateAllFields was first introduced.
23+
// CAREFUL: Test failure may indicate forward compatibility breakage.
24+
// Only active RP releases (potentially multiple) need to be supported.
25+
// Investigate thoroughly before updating.
26+
forwardCompatCommit = "878718c80db175859a6d7cf377f7b7d40c438413"
27+
)
28+
29+
var (
30+
fetchOnce sync.Once
31+
)
32+
33+
// TestMain fetches required commits before running tests to avoid git lock conflicts in CI.
34+
func TestMain(m *testing.M) {
35+
// Fetch commits needed for compatibility tests (only matters in CI with shallow clones)
36+
fetchCommitsOnce()
37+
os.Exit(m.Run())
38+
}
39+
40+
// fetchCommitsOnce fetches required commits exactly once to avoid git lock conflicts.
41+
func fetchCommitsOnce() {
42+
fetchOnce.Do(func() {
43+
fetchCommitsForTests(backwardCompatCommit, forwardCompatCommit)
44+
})
45+
}
46+
47+
// setupWorktree creates a git worktree at the specified commit and returns its path.
48+
func setupWorktree(t *testing.T, targetCommit string) string {
49+
worktreePath := t.TempDir()
50+
cmd := exec.Command("git", "worktree", "add", worktreePath, targetCommit)
51+
output, err := cmd.CombinedOutput()
52+
require.NoError(t, err, "Failed to create worktree: %s", output)
53+
54+
t.Cleanup(func() {
55+
_ = exec.Command("git", "worktree", "remove", "--force", worktreePath).Run()
56+
})
57+
58+
// Log commit info
59+
cmd = exec.Command("git", "log", "-1", "--oneline", targetCommit)
60+
if commitInfo, err := cmd.CombinedOutput(); err == nil {
61+
t.Logf("Testing against: %q", strings.TrimSpace(string(commitInfo)))
62+
}
63+
64+
return worktreePath
65+
}
66+
67+
// fetchCommitsForTests fetches required commits for compatibility tests in CI shallow clones.
68+
// This is called once before tests run to avoid git lock conflicts between parallel fetches.
69+
func fetchCommitsForTests(commits ...string) {
70+
for _, commit := range commits {
71+
cmd := exec.Command("git", "fetch", "--depth=1", "origin", commit)
72+
_ = cmd.Run() // Ignore errors - commit might already exist
73+
}
74+
}
75+
76+
// TestBackwardCompatibility tests that old code can unmarshal configs generated by current code.
77+
func TestBackwardCompatibility(t *testing.T) {
78+
t.Logf("Testing backward compatibility with commit: %s", backwardCompatCommit)
79+
80+
// Generate JSON with current code
81+
cfg := &aksnodeconfigv1.Configuration{}
82+
PopulateAllFields(cfg)
83+
84+
currentJSON, err := MarshalConfigurationV1(cfg)
85+
require.NoError(t, err)
86+
t.Logf("Generated %d bytes of JSON with current code", len(currentJSON))
87+
88+
// Create git worktree at target commit
89+
worktreePath := setupWorktree(t, backwardCompatCommit)
90+
91+
// Write JSON to worktree
92+
pkgPath := filepath.Join(worktreePath, "aks-node-controller", "pkg", "nodeconfigutils")
93+
jsonPath := filepath.Join(pkgPath, "test_compat.json")
94+
require.NoError(t, os.WriteFile(jsonPath, currentJSON, 0644))
95+
96+
// Create test file to unmarshal with old code
97+
testCode := `package nodeconfigutils
98+
import (
99+
"os"
100+
"testing"
101+
"github.com/stretchr/testify/require"
102+
)
103+
func TestUnmarshalCompat(t *testing.T) {
104+
data, err := os.ReadFile("test_compat.json")
105+
require.NoError(t, err)
106+
cfg, err := UnmarshalConfigurationV1(data)
107+
require.NoError(t, err, "Old code should unmarshal current JSON")
108+
require.NotNil(t, cfg)
109+
t.Logf("✓ Successfully unmarshaled %d bytes", len(data))
110+
}
111+
`
112+
testFile := filepath.Join(pkgPath, "compat_test.go")
113+
require.NoError(t, os.WriteFile(testFile, []byte(testCode), 0644))
114+
115+
// Run test with old code
116+
cmd := exec.Command("go", "test", "-v", "-run", "TestUnmarshalCompat", "./pkg/nodeconfigutils")
117+
cmd.Dir = filepath.Join(worktreePath, "aks-node-controller")
118+
output, err := cmd.CombinedOutput()
119+
120+
t.Logf("Old code output:\n%s", output)
121+
require.NoError(t, err, "❌ BACKWARD COMPATIBILITY BROKEN: Old code (%s) cannot unmarshal current JSON", backwardCompatCommit)
122+
}
123+
124+
// TestForwardCompatibility tests that current code can unmarshal configs generated by old code.
125+
func TestForwardCompatibility(t *testing.T) {
126+
t.Logf("Testing forward compatibility with commit: %s", forwardCompatCommit)
127+
128+
// Create git worktree at target commit
129+
worktreePath := setupWorktree(t, forwardCompatCommit)
130+
pkgPath := filepath.Join(worktreePath, "aks-node-controller", "pkg", "nodeconfigutils")
131+
132+
// Try to generate JSON with old code using PopulateAllFields
133+
generateCode := `package nodeconfigutils
134+
import (
135+
"os"
136+
"testing"
137+
aksnodeconfigv1 "github.com/Azure/agentbaker/aks-node-controller/pkg/gen/aksnodeconfig/v1"
138+
"github.com/stretchr/testify/require"
139+
)
140+
func TestGenerateCompat(t *testing.T) {
141+
cfg := &aksnodeconfigv1.Configuration{}
142+
PopulateAllFields(cfg)
143+
data, err := MarshalConfigurationV1(cfg)
144+
require.NoError(t, err)
145+
require.NoError(t, os.WriteFile("old_config.json", data, 0644))
146+
t.Logf("Generated %d bytes", len(data))
147+
}
148+
`
149+
generateFile := filepath.Join(pkgPath, "generate_compat_test.go")
150+
require.NoError(t, os.WriteFile(generateFile, []byte(generateCode), 0644))
151+
152+
// Run the generation test
153+
cmd := exec.Command("go", "test", "-v", "-run", "TestGenerateCompat", "./pkg/nodeconfigutils")
154+
cmd.Dir = filepath.Join(worktreePath, "aks-node-controller")
155+
output, err := cmd.CombinedOutput()
156+
157+
t.Logf("Old code generation output:\n%s", output)
158+
require.NoError(t, err, "Old code failed to generate JSON")
159+
160+
// Read generated JSON
161+
jsonPath := filepath.Join(pkgPath, "old_config.json")
162+
oldJSON, err := os.ReadFile(jsonPath)
163+
require.NoError(t, err)
164+
t.Logf("Generated %d bytes of JSON with old code", len(oldJSON))
165+
166+
// Unmarshal with current code
167+
cfg, err := UnmarshalConfigurationV1(oldJSON)
168+
require.NoError(t, err, "❌ FORWARD COMPATIBILITY BROKEN: Current code cannot unmarshal old JSON (%s)", forwardCompatCommit)
169+
require.NotNil(t, cfg)
170+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package nodeconfigutils
2+
3+
import (
4+
"fmt"
5+
6+
"google.golang.org/protobuf/proto"
7+
"google.golang.org/protobuf/reflect/protoreflect"
8+
)
9+
10+
// PopulateAllFields recursively populates all fields in a protobuf message with non-zero test values.
11+
// This is used for testing to ensure all fields can be marshaled/unmarshaled correctly.
12+
func PopulateAllFields(msg proto.Message) {
13+
populateMessage(msg.ProtoReflect(), 0)
14+
}
15+
16+
func populateMessage(msg protoreflect.Message, depth int) {
17+
// Prevent infinite recursion for deeply nested structures
18+
if depth > 10 {
19+
return
20+
}
21+
22+
// Iterate over all field descriptors (including unset ones)
23+
fields := msg.Descriptor().Fields()
24+
for i := 0; i < fields.Len(); i++ {
25+
fd := fields.Get(i)
26+
setFieldValue(msg, fd, depth)
27+
}
28+
}
29+
30+
func setFieldValue(msg protoreflect.Message, fd protoreflect.FieldDescriptor, depth int) {
31+
switch {
32+
case fd.IsList():
33+
// Handle repeated fields - add 2 elements
34+
list := msg.Mutable(fd).List()
35+
for j := 0; j < 2; j++ {
36+
if fd.Kind() == protoreflect.MessageKind {
37+
// For repeated message fields, create new message and populate it
38+
elem := list.NewElement()
39+
populateMessage(elem.Message(), depth+1)
40+
list.Append(elem)
41+
} else {
42+
val := getDefaultValueForField(fd, fmt.Sprintf("item%d", j))
43+
list.Append(val)
44+
}
45+
}
46+
case fd.IsMap():
47+
// Handle map fields - add 2 entries
48+
mapVal := msg.Mutable(fd).Map()
49+
for j := 0; j < 2; j++ {
50+
key := getDefaultKeyForKind(fd.MapKey().Kind(), j)
51+
if fd.MapValue().Kind() == protoreflect.MessageKind {
52+
// For map values that are messages, create and populate
53+
val := mapVal.NewValue()
54+
populateMessage(val.Message(), depth+1)
55+
mapVal.Set(key, val)
56+
} else {
57+
val := getDefaultValueForMapValue(fd.MapValue(), j)
58+
mapVal.Set(key, val)
59+
}
60+
}
61+
case fd.Kind() == protoreflect.MessageKind:
62+
// Handle singular message fields - use Mutable to get/create the message
63+
nestedMsg := msg.Mutable(fd).Message()
64+
populateMessage(nestedMsg, depth+1)
65+
default:
66+
// Handle singular primitive fields
67+
val := getDefaultValueForField(fd, "")
68+
msg.Set(fd, val)
69+
}
70+
}
71+
72+
func getDefaultValueForField(fd protoreflect.FieldDescriptor, suffix string) protoreflect.Value {
73+
switch fd.Kind() {
74+
case protoreflect.BoolKind:
75+
return protoreflect.ValueOfBool(true)
76+
case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind:
77+
return protoreflect.ValueOfInt32(42)
78+
case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind:
79+
return protoreflect.ValueOfInt64(42)
80+
case protoreflect.Uint32Kind, protoreflect.Fixed32Kind:
81+
return protoreflect.ValueOfUint32(42)
82+
case protoreflect.Uint64Kind, protoreflect.Fixed64Kind:
83+
return protoreflect.ValueOfUint64(42)
84+
case protoreflect.FloatKind:
85+
return protoreflect.ValueOfFloat32(42.0)
86+
case protoreflect.DoubleKind:
87+
return protoreflect.ValueOfFloat64(42.0)
88+
case protoreflect.StringKind:
89+
fieldName := string(fd.Name())
90+
if suffix != "" {
91+
return protoreflect.ValueOfString(fmt.Sprintf("test-%s-%s", fieldName, suffix))
92+
}
93+
return protoreflect.ValueOfString(fmt.Sprintf("test-%s-value", fieldName))
94+
case protoreflect.BytesKind:
95+
return protoreflect.ValueOfBytes([]byte(fmt.Sprintf("test-bytes-%s", fd.Name())))
96+
case protoreflect.EnumKind:
97+
// Use the last enum value (latest/most recent)
98+
enumDesc := fd.Enum()
99+
lastIndex := enumDesc.Values().Len() - 1
100+
if lastIndex >= 0 {
101+
return protoreflect.ValueOfEnum(enumDesc.Values().Get(lastIndex).Number())
102+
}
103+
return protoreflect.ValueOfEnum(0)
104+
case protoreflect.MessageKind, protoreflect.GroupKind:
105+
// Message fields should be handled in setFieldValue using Mutable
106+
// This shouldn't be called for message fields
107+
panic(fmt.Sprintf("getDefaultValueForField called for message/group field %q - this is a bug", fd.FullName()))
108+
default:
109+
panic(fmt.Sprintf("getDefaultValueForField: unsupported field kind %v for field %q", fd.Kind(), fd.FullName()))
110+
}
111+
}
112+
113+
func getDefaultKeyForKind(kind protoreflect.Kind, index int) protoreflect.MapKey {
114+
switch kind {
115+
case protoreflect.StringKind:
116+
return protoreflect.ValueOfString(fmt.Sprintf("test-key-%d", index)).MapKey()
117+
case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind:
118+
return protoreflect.ValueOfInt32(int32(index + 1)).MapKey() //nolint:gosec // Index is always 0 or 1, no overflow risk
119+
case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind:
120+
return protoreflect.ValueOfInt64(int64(index + 1)).MapKey()
121+
case protoreflect.Uint32Kind, protoreflect.Fixed32Kind:
122+
return protoreflect.ValueOfUint32(uint32(index + 1)).MapKey() //nolint:gosec // Index is always 0 or 1, no overflow risk
123+
case protoreflect.Uint64Kind, protoreflect.Fixed64Kind:
124+
return protoreflect.ValueOfUint64(uint64(index + 1)).MapKey() //nolint:gosec // Index is always 0 or 1, no overflow risk
125+
case protoreflect.BoolKind:
126+
return protoreflect.ValueOfBool(index == 0).MapKey()
127+
case protoreflect.FloatKind, protoreflect.DoubleKind, protoreflect.BytesKind,
128+
protoreflect.EnumKind, protoreflect.MessageKind, protoreflect.GroupKind:
129+
// These types are not valid map key types in protobuf
130+
panic(fmt.Sprintf("getDefaultKeyForKind: invalid map key type %v", kind))
131+
default:
132+
panic(fmt.Sprintf("getDefaultKeyForKind: unsupported kind %v", kind))
133+
}
134+
}
135+
136+
func getDefaultValueForMapValue(fd protoreflect.FieldDescriptor, index int) protoreflect.Value {
137+
suffix := fmt.Sprintf("map%d", index)
138+
return getDefaultValueForField(fd, suffix)
139+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package nodeconfigutils
2+
3+
import (
4+
"testing"
5+
6+
aksnodeconfigv1 "github.com/Azure/agentbaker/aks-node-controller/pkg/gen/aksnodeconfig/v1"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestPopulateAllFields(t *testing.T) {
11+
cfg := &aksnodeconfigv1.Configuration{}
12+
PopulateAllFields(cfg)
13+
14+
// Verify a few key fields are populated
15+
assert.NotEmpty(t, cfg.Version)
16+
assert.NotEmpty(t, cfg.VmSize)
17+
assert.NotNil(t, cfg.ClusterConfig)
18+
assert.NotEmpty(t, cfg.ClusterConfig.Location)
19+
20+
// Verify enum is set to non-zero value
21+
assert.NotEqual(t, aksnodeconfigv1.WorkloadRuntime_WORKLOAD_RUNTIME_UNSPECIFIED, cfg.WorkloadRuntime)
22+
}

0 commit comments

Comments
 (0)