Skip to content

Commit d51de4a

Browse files
committed
fix(api): Add custom JSON marshaling for InstancePrototype interface fields
Instance provisioning fails with "Expected only one oneOf fields to be set: got 0" errors when using InstancePrototype types with interface-typed fields. Go's default JSON marshaling omits interface-typed fields in certain contexts. oneOf discriminator fields (image, zone, vpc, primary_network_attachment) are missing from the JSON payload, causing VPC API validation to fail. This commit adds custom MarshalJSON methods to all InstancePrototype variant types that explicitly serialize all fields including interface types. Adds MarshalJSON() method requirement to InstancePrototypeIntf interface. This only affects code that implements this interface (SDK-generated types already comply). Fixes: #133 Signed-off-by: Josephine Pfeiffer <[email protected]>
1 parent 166095d commit d51de4a

File tree

3 files changed

+1434
-6
lines changed

3 files changed

+1434
-6
lines changed
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
package vpcv1_test
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
8+
"github.com/IBM/vpc-go-sdk/vpcv1"
9+
)
10+
11+
func TestInstancePrototypeMarshalBug(t *testing.T) {
12+
// Create the prototype exactly like Karpenter does
13+
imageID := "r010-test-image-id"
14+
zoneName := "eu-de-2"
15+
profileName := "bx2-2x8"
16+
vpcID := "r010-test-vpc-id"
17+
instanceName := "test-instance"
18+
subnetID := "test-subnet-id"
19+
20+
// Create VNI prototype
21+
vniPrototype := &vpcv1.InstanceNetworkAttachmentPrototypeVirtualNetworkInterfaceVirtualNetworkInterfacePrototypeInstanceNetworkAttachmentContext{
22+
Subnet: &vpcv1.SubnetIdentityByID{
23+
ID: &subnetID,
24+
},
25+
}
26+
27+
primaryNetworkAttachment := &vpcv1.InstanceNetworkAttachmentPrototype{
28+
VirtualNetworkInterface: vniPrototype,
29+
}
30+
31+
// Create instance prototype
32+
instancePrototype := &vpcv1.InstancePrototypeInstanceByImageInstanceByImageInstanceByNetworkAttachment{
33+
Image: &vpcv1.ImageIdentityByID{ID: &imageID},
34+
Zone: &vpcv1.ZoneIdentityByName{Name: &zoneName},
35+
Profile: &vpcv1.InstanceProfileIdentityByName{Name: &profileName},
36+
VPC: &vpcv1.VPCIdentityByID{ID: &vpcID},
37+
Name: &instanceName,
38+
PrimaryNetworkAttachment: primaryNetworkAttachment,
39+
}
40+
41+
// Verify fields are set
42+
if instancePrototype.Image == nil {
43+
t.Fatal("Image field should not be nil")
44+
}
45+
if instancePrototype.Zone == nil {
46+
t.Fatal("Zone field should not be nil")
47+
}
48+
if instancePrototype.VPC == nil {
49+
t.Fatal("VPC field should not be nil")
50+
}
51+
if instancePrototype.Profile == nil {
52+
t.Fatal("Profile field should not be nil")
53+
}
54+
55+
// Marshal to JSON
56+
jsonBytes, err := json.MarshalIndent(instancePrototype, "", " ")
57+
if err != nil {
58+
t.Fatalf("JSON marshaling failed: %v", err)
59+
}
60+
61+
jsonString := string(jsonBytes)
62+
t.Logf("Marshaled JSON:\n%s\n", jsonString)
63+
64+
// These assertions will FAIL if the bug exists
65+
if !strings.Contains(jsonString, `"image"`) {
66+
t.Error("JSON should contain image field - BUG CONFIRMED")
67+
}
68+
if !strings.Contains(jsonString, `"zone"`) {
69+
t.Error("JSON should contain zone field - BUG CONFIRMED")
70+
}
71+
if !strings.Contains(jsonString, `"vpc"`) {
72+
t.Error("JSON should contain vpc field - BUG CONFIRMED")
73+
}
74+
if !strings.Contains(jsonString, `"profile"`) {
75+
t.Error("JSON should contain profile field - BUG CONFIRMED")
76+
}
77+
78+
// Verify actual values
79+
if !strings.Contains(jsonString, imageID) {
80+
t.Error("JSON should contain image ID value")
81+
}
82+
if !strings.Contains(jsonString, zoneName) {
83+
t.Error("JSON should contain zone name value")
84+
}
85+
if !strings.Contains(jsonString, vpcID) {
86+
t.Error("JSON should contain VPC ID value")
87+
}
88+
if !strings.Contains(jsonString, profileName) {
89+
t.Error("JSON should contain profile name value")
90+
}
91+
}
92+
93+
func TestInstancePrototypeInterfaceMarshalBug(t *testing.T) {
94+
// This test verifies that marshaling through the InstancePrototypeIntf interface
95+
// correctly calls the concrete type's MarshalJSON method.
96+
// This is the CRITICAL fix - without it, interface marshaling drops fields.
97+
98+
imageID := "r010-test-image-id"
99+
zoneName := "eu-de-2"
100+
profileName := "bx2-2x8"
101+
vpcID := "r010-test-vpc-id"
102+
instanceName := "test-instance"
103+
subnetID := "test-subnet-id"
104+
105+
// Create VNI prototype
106+
vniPrototype := &vpcv1.InstanceNetworkAttachmentPrototypeVirtualNetworkInterfaceVirtualNetworkInterfacePrototypeInstanceNetworkAttachmentContext{
107+
Subnet: &vpcv1.SubnetIdentityByID{
108+
ID: &subnetID,
109+
},
110+
}
111+
112+
primaryNetworkAttachment := &vpcv1.InstanceNetworkAttachmentPrototype{
113+
VirtualNetworkInterface: vniPrototype,
114+
}
115+
116+
// Create concrete prototype
117+
concretePrototype := &vpcv1.InstancePrototypeInstanceByImageInstanceByImageInstanceByNetworkAttachment{
118+
Image: &vpcv1.ImageIdentityByID{ID: &imageID},
119+
Zone: &vpcv1.ZoneIdentityByName{Name: &zoneName},
120+
Profile: &vpcv1.InstanceProfileIdentityByName{Name: &profileName},
121+
VPC: &vpcv1.VPCIdentityByID{ID: &vpcID},
122+
Name: &instanceName,
123+
PrimaryNetworkAttachment: primaryNetworkAttachment,
124+
}
125+
126+
// Cast to interface type (THIS is how the SDK uses it)
127+
var interfacePrototype vpcv1.InstancePrototypeIntf = concretePrototype
128+
129+
// Marshal through the INTERFACE (critical test)
130+
jsonBytes, err := json.MarshalIndent(interfacePrototype, "", " ")
131+
if err != nil {
132+
t.Fatalf("JSON marshaling through interface failed: %v", err)
133+
}
134+
135+
jsonString := string(jsonBytes)
136+
t.Logf("Marshaled JSON through interface:\n%s\n", jsonString)
137+
138+
// These assertions verify the interface marshaling works
139+
if !strings.Contains(jsonString, `"image"`) {
140+
t.Error("JSON should contain image field when marshaled through interface - INTERFACE BUG CONFIRMED")
141+
}
142+
if !strings.Contains(jsonString, `"zone"`) {
143+
t.Error("JSON should contain zone field when marshaled through interface - INTERFACE BUG CONFIRMED")
144+
}
145+
if !strings.Contains(jsonString, `"vpc"`) {
146+
t.Error("JSON should contain vpc field when marshaled through interface - INTERFACE BUG CONFIRMED")
147+
}
148+
if !strings.Contains(jsonString, `"profile"`) {
149+
t.Error("JSON should contain profile field when marshaled through interface - INTERFACE BUG CONFIRMED")
150+
}
151+
152+
// Verify actual values
153+
if !strings.Contains(jsonString, imageID) {
154+
t.Error("JSON should contain image ID value when marshaled through interface")
155+
}
156+
if !strings.Contains(jsonString, zoneName) {
157+
t.Error("JSON should contain zone name value when marshaled through interface")
158+
}
159+
if !strings.Contains(jsonString, vpcID) {
160+
t.Error("JSON should contain VPC ID value when marshaled through interface")
161+
}
162+
if !strings.Contains(jsonString, profileName) {
163+
t.Error("JSON should contain profile name value when marshaled through interface")
164+
}
165+
}
166+
167+
func TestBaseInstancePrototypeMarshal(t *testing.T) {
168+
// Test the base InstancePrototype type marshaling
169+
imageID := "r010-test-image-id"
170+
zoneName := "eu-de-2"
171+
profileName := "bx2-2x8"
172+
vpcID := "r010-test-vpc-id"
173+
instanceName := "test-instance"
174+
175+
basePrototype := &vpcv1.InstancePrototype{
176+
Image: &vpcv1.ImageIdentityByID{ID: &imageID},
177+
Zone: &vpcv1.ZoneIdentityByName{Name: &zoneName},
178+
Profile: &vpcv1.InstanceProfileIdentityByName{Name: &profileName},
179+
VPC: &vpcv1.VPCIdentityByID{ID: &vpcID},
180+
Name: &instanceName,
181+
}
182+
183+
// Marshal to JSON
184+
jsonBytes, err := json.MarshalIndent(basePrototype, "", " ")
185+
if err != nil {
186+
t.Fatalf("JSON marshaling failed: %v", err)
187+
}
188+
189+
jsonString := string(jsonBytes)
190+
t.Logf("Marshaled base prototype JSON:\n%s\n", jsonString)
191+
192+
// Verify interface fields are present
193+
if !strings.Contains(jsonString, `"image"`) {
194+
t.Error("Base prototype JSON should contain image field")
195+
}
196+
if !strings.Contains(jsonString, `"zone"`) {
197+
t.Error("Base prototype JSON should contain zone field")
198+
}
199+
if !strings.Contains(jsonString, `"vpc"`) {
200+
t.Error("Base prototype JSON should contain vpc field")
201+
}
202+
if !strings.Contains(jsonString, `"profile"`) {
203+
t.Error("Base prototype JSON should contain profile field")
204+
}
205+
}
206+
207+
func TestInstancePrototypeInstanceByImageMarshal(t *testing.T) {
208+
imageID := "r010-test-image-id"
209+
zoneName := "eu-de-2"
210+
profileName := "bx2-2x8"
211+
vpcID := "r010-test-vpc-id"
212+
instanceName := "test-instance"
213+
214+
prototype := &vpcv1.InstancePrototypeInstanceByImage{
215+
Image: &vpcv1.ImageIdentityByID{ID: &imageID},
216+
Zone: &vpcv1.ZoneIdentityByName{Name: &zoneName},
217+
Profile: &vpcv1.InstanceProfileIdentityByName{Name: &profileName},
218+
VPC: &vpcv1.VPCIdentityByID{ID: &vpcID},
219+
Name: &instanceName,
220+
}
221+
222+
jsonBytes, err := json.Marshal(prototype)
223+
if err != nil {
224+
t.Fatalf("JSON marshaling failed: %v", err)
225+
}
226+
227+
jsonString := string(jsonBytes)
228+
if !strings.Contains(jsonString, `"image"`) || !strings.Contains(jsonString, `"zone"`) ||
229+
!strings.Contains(jsonString, `"vpc"`) || !strings.Contains(jsonString, `"profile"`) {
230+
t.Errorf("InstancePrototypeInstanceByImage missing interface fields in JSON: %s", jsonString)
231+
}
232+
}
233+
234+
func TestInstancePrototypeInstanceByCatalogOfferingMarshal(t *testing.T) {
235+
catalogOfferingVersionCRN := "crn:test:offering"
236+
zoneName := "eu-de-2"
237+
profileName := "bx2-2x8"
238+
vpcID := "r010-test-vpc-id"
239+
instanceName := "test-instance"
240+
241+
prototype := &vpcv1.InstancePrototypeInstanceByCatalogOffering{
242+
CatalogOffering: &vpcv1.InstanceCatalogOfferingPrototypeCatalogOfferingByVersion{
243+
Version: &vpcv1.CatalogOfferingVersionIdentityCatalogOfferingVersionByCRN{
244+
CRN: &catalogOfferingVersionCRN,
245+
},
246+
},
247+
Zone: &vpcv1.ZoneIdentityByName{Name: &zoneName},
248+
Profile: &vpcv1.InstanceProfileIdentityByName{Name: &profileName},
249+
VPC: &vpcv1.VPCIdentityByID{ID: &vpcID},
250+
Name: &instanceName,
251+
}
252+
253+
jsonBytes, err := json.Marshal(prototype)
254+
if err != nil {
255+
t.Fatalf("JSON marshaling failed: %v", err)
256+
}
257+
258+
jsonString := string(jsonBytes)
259+
if !strings.Contains(jsonString, `"catalog_offering"`) || !strings.Contains(jsonString, `"zone"`) ||
260+
!strings.Contains(jsonString, `"vpc"`) || !strings.Contains(jsonString, `"profile"`) {
261+
t.Errorf("InstancePrototypeInstanceByCatalogOffering missing interface fields in JSON: %s", jsonString)
262+
}
263+
}
264+
265+
func TestInstancePrototypeInstanceBySourceSnapshotMarshal(t *testing.T) {
266+
snapshotID := "r010-test-snapshot-id"
267+
zoneName := "eu-de-2"
268+
profileName := "bx2-2x8"
269+
vpcID := "r010-test-vpc-id"
270+
instanceName := "test-instance"
271+
272+
prototype := &vpcv1.InstancePrototypeInstanceBySourceSnapshot{
273+
BootVolumeAttachment: &vpcv1.VolumeAttachmentPrototypeInstanceBySourceSnapshotContext{
274+
Volume: &vpcv1.VolumePrototypeInstanceBySourceSnapshotContext{
275+
SourceSnapshot: &vpcv1.SnapshotIdentityByID{ID: &snapshotID},
276+
},
277+
},
278+
Zone: &vpcv1.ZoneIdentityByName{Name: &zoneName},
279+
Profile: &vpcv1.InstanceProfileIdentityByName{Name: &profileName},
280+
VPC: &vpcv1.VPCIdentityByID{ID: &vpcID},
281+
Name: &instanceName,
282+
}
283+
284+
jsonBytes, err := json.Marshal(prototype)
285+
if err != nil {
286+
t.Fatalf("JSON marshaling failed: %v", err)
287+
}
288+
289+
jsonString := string(jsonBytes)
290+
if !strings.Contains(jsonString, `"boot_volume_attachment"`) || !strings.Contains(jsonString, `"zone"`) ||
291+
!strings.Contains(jsonString, `"vpc"`) || !strings.Contains(jsonString, `"profile"`) {
292+
t.Errorf("InstancePrototypeInstanceBySourceSnapshot missing interface fields in JSON: %s", jsonString)
293+
}
294+
}
295+
296+
func TestInstancePrototypeInstanceBySourceTemplateMarshal(t *testing.T) {
297+
templateID := "r010-test-template-id"
298+
zoneName := "eu-de-2"
299+
instanceName := "test-instance"
300+
301+
prototype := &vpcv1.InstancePrototypeInstanceBySourceTemplate{
302+
SourceTemplate: &vpcv1.InstanceTemplateIdentityByID{ID: &templateID},
303+
Zone: &vpcv1.ZoneIdentityByName{Name: &zoneName},
304+
Name: &instanceName,
305+
}
306+
307+
jsonBytes, err := json.Marshal(prototype)
308+
if err != nil {
309+
t.Fatalf("JSON marshaling failed: %v", err)
310+
}
311+
312+
jsonString := string(jsonBytes)
313+
if !strings.Contains(jsonString, `"source_template"`) || !strings.Contains(jsonString, `"zone"`) {
314+
t.Errorf("InstancePrototypeInstanceBySourceTemplate missing interface fields in JSON: %s", jsonString)
315+
}
316+
}
317+
318+
func TestInstancePrototypeInstanceByVolumeMarshal(t *testing.T) {
319+
volumeID := "r010-test-volume-id"
320+
zoneName := "eu-de-2"
321+
profileName := "bx2-2x8"
322+
vpcID := "r010-test-vpc-id"
323+
instanceName := "test-instance"
324+
325+
prototype := &vpcv1.InstancePrototypeInstanceByVolume{
326+
BootVolumeAttachment: &vpcv1.VolumeAttachmentPrototypeInstanceByVolumeContext{
327+
Volume: &vpcv1.VolumeIdentityByID{ID: &volumeID},
328+
},
329+
Zone: &vpcv1.ZoneIdentityByName{Name: &zoneName},
330+
Profile: &vpcv1.InstanceProfileIdentityByName{Name: &profileName},
331+
VPC: &vpcv1.VPCIdentityByID{ID: &vpcID},
332+
Name: &instanceName,
333+
}
334+
335+
jsonBytes, err := json.Marshal(prototype)
336+
if err != nil {
337+
t.Fatalf("JSON marshaling failed: %v", err)
338+
}
339+
340+
jsonString := string(jsonBytes)
341+
if !strings.Contains(jsonString, `"boot_volume_attachment"`) || !strings.Contains(jsonString, `"zone"`) ||
342+
!strings.Contains(jsonString, `"vpc"`) || !strings.Contains(jsonString, `"profile"`) {
343+
t.Errorf("InstancePrototypeInstanceByVolume missing interface fields in JSON: %s", jsonString)
344+
}
345+
}

0 commit comments

Comments
 (0)