diff --git a/load.go b/load.go index 2be576713..0d6fc9148 100644 --- a/load.go +++ b/load.go @@ -210,36 +210,80 @@ func (s *Spec) SubstituteArgs(env map[string]string, opts ...SubstituteOpt) erro func LoadSpec(dt []byte) (*Spec, error) { var spec Spec - dt, err := stripXFields(dt) - if err != nil { - return nil, fmt.Errorf("error stripping x-fields: %w", err) - } - if err := yaml.UnmarshalWithOptions(dt, &spec, yaml.Strict()); err != nil { - return nil, fmt.Errorf("error unmarshalling spec: %w", err) + return nil, errors.Wrap(err, "error unmarshalling spec") } if err := spec.Validate(); err != nil { return nil, err } - spec.FillDefaults() + spec.FillDefaults() return &spec, nil } -func stripXFields(dt []byte) ([]byte, error) { +func (s *Spec) UnmarshalYAML(f func(interface{}) error) error { var obj map[string]interface{} - if err := yaml.Unmarshal(dt, &obj); err != nil { - return nil, fmt.Errorf("error unmarshalling spec: %w", err) + + if err := f(&obj); err != nil { + return err + } + + var ext map[string]interface{} + + addExt := func(k string, v interface{}) { + if ext == nil { + ext = make(map[string]interface{}) + } + ext[k] = v + } + for k, v := range obj { + if !strings.HasPrefix(k, "x-") && !strings.HasPrefix(k, "X-") { + continue + } + addExt(strings.TrimPrefix(k, "x-"), v) + delete(obj, k) + } + + type internalSpec Spec + var s2 internalSpec + + dt, err := yaml.Marshal(obj) + if err != nil { + return err + } + + if err := yaml.UnmarshalWithOptions(dt, &s2, yaml.Strict()); err != nil { + return err + } + + *s = Spec(s2) + s.Ext = ext + return nil +} + +func (s Spec) MarshalYAML() ([]byte, error) { + // We need to define a new type to avoid infinite recursion of MarshalYAML. + type internalSpec Spec + + type specExt struct { + internalSpec `yaml:",inline"` + Ext map[string]interface{} `yaml:",omitempty,inline"` } - for k := range obj { - if strings.HasPrefix(k, "x-") || strings.HasPrefix(k, "X-") { - delete(obj, k) + var ext map[string]interface{} + if len(s.Ext) > 0 { + ext = maps.Clone(s.Ext) + for k, v := range ext { + delete(ext, k) + ext["x-"+k] = v } } - return yaml.Marshal(obj) + return yaml.Marshal(specExt{ + internalSpec: internalSpec(s), + Ext: ext, + }) } func (s *BuildStep) processBuildArgs(lex *shell.Lex, args map[string]string, allowArg func(string) bool) error { diff --git a/load_test.go b/load_test.go index 4b8104586..d29fa0830 100644 --- a/load_test.go +++ b/load_test.go @@ -10,6 +10,7 @@ import ( "reflect" "testing" + "github.com/goccy/go-yaml" "github.com/moby/buildkit/frontend/dockerui" "gotest.tools/v3/assert" "gotest.tools/v3/assert/cmp" @@ -1160,3 +1161,38 @@ func TestImage_validate(t *testing.T) { }) } } + +func TestExtensionFieldMarshalUnmarshal(t *testing.T) { + dt := []byte(` +name: test +x-hello: world +x-foo: +- bar +- baz +`) + + var spec Spec + err := yaml.Unmarshal(dt, &spec) + assert.NilError(t, err) + + assert.Check(t, cmp.Equal(spec.Name, "test"), spec) + assert.Assert(t, spec.Ext != nil) + assert.Check(t, spec.Ext["hello"] != nil, "%+v", spec) + assert.Check(t, cmp.Equal(spec.Ext["hello"].(string), "world"), "%+v", spec.Ext) + assert.Check(t, cmp.DeepEqual(spec.Ext["foo"].([]interface{}), []interface{}{"bar", "baz"}), "%+v", spec.Ext) + + // marshal and unmarshal to ensure the extension fields are preserved + + dt, err = yaml.Marshal(spec) + assert.NilError(t, err) + + var spec2 Spec + err = yaml.Unmarshal(dt, &spec2) + assert.NilError(t, err) + + assert.Check(t, cmp.Equal(spec2.Name, "test"), spec2) + assert.Assert(t, spec2.Ext != nil, string(dt)) + assert.Check(t, spec2.Ext["hello"] != nil, "%+v", spec2.Ext) + assert.Check(t, cmp.Equal(spec2.Ext["hello"].(string), "world"), "%+v", spec2.Ext) + assert.Check(t, cmp.DeepEqual(spec2.Ext["foo"].([]interface{}), []interface{}{"bar", "baz"}), "%+v", spec2.Ext) +} diff --git a/spec.go b/spec.go index da4251fca..7f90d8763 100644 --- a/spec.go +++ b/spec.go @@ -94,6 +94,8 @@ type Spec struct { // Each item in this list is run with a separate rootfs and cannot interact with other tests. // Each [TestSpec] is run with a separate rootfs, asynchronously from other [TestSpec]. Tests []*TestSpec `yaml:"tests,omitempty" json:"tests,omitempty"` + + Ext map[string]interface{} `yaml:"-" json:"-"` } // PatchSpec is used to apply a patch to a source with a given set of options.