Skip to content
Merged
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
27 changes: 27 additions & 0 deletions internal/fixture/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,33 @@ func (f UpdateObject) preprocess(parser Parser) (Operation, error) {
return f, nil
}

// ChangeParser is a type of operation. It simulates making changes a schema without versioning
// the schema. This can be used to test the behavior of making backward compatible schema changes,
// e.g. setting "elementRelationship: atomic" on an existing struct. It also may be used to ensure
// that backward incompatible changes are detected appropriately.
type ChangeParser struct {
Parser *typed.Parser
}

var _ Operation = &ChangeParser{}

func (cs ChangeParser) run(state *State) error {
state.Parser = cs.Parser
// Swap the schema in for use with the live object so it merges.
// If the schema is incompatible, this will fail validation.

liveWithNewSchema, err := typed.AsTyped(state.Live.AsValue(), &cs.Parser.Schema, state.Live.TypeRef())
if err != nil {
return err
}
state.Live = liveWithNewSchema
return nil
}

func (cs ChangeParser) preprocess(_ Parser) (Operation, error) {
return cs, nil
}

// TestCase is the list of operations that need to be run, as well as
// the object/managedfields as they are supposed to look like after all
// the operations have been successfully performed. If Object/Managed is
Expand Down
252 changes: 252 additions & 0 deletions merge/schema_change_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/*
Copyright 2018 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package merge_test

import (
"testing"

"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
. "sigs.k8s.io/structured-merge-diff/v4/internal/fixture"
"sigs.k8s.io/structured-merge-diff/v4/merge"
"sigs.k8s.io/structured-merge-diff/v4/typed"
)

var structParser = func() *typed.Parser {
oldParser, err := typed.NewParser(`types:
- name: v1
map:
fields:
- name: struct
type:
namedType: struct
- name: struct
map:
fields:
- name: numeric
type:
scalar: numeric
- name: string
type:
scalar: string`)
if err != nil {
panic(err)
}
return oldParser
}()

var structWithAtomicParser = func() *typed.Parser {
newParser, err := typed.NewParser(`types:
- name: v1
map:
fields:
- name: struct
type:
namedType: struct
- name: struct
map:
fields:
- name: numeric
type:
scalar: numeric
- name: string
type:
scalar: string
elementRelationship: atomic`)
if err != nil {
panic(err)
}
return newParser
}()

func TestGranularToAtomicSchemaChanges(t *testing.T) {
tests := map[string]TestCase{
"to-atomic": {
Ops: []Operation{
Apply{
Manager: "one",
Object: `
struct:
numeric: 1
`,
APIVersion: "v1",
},
ChangeParser{Parser: structWithAtomicParser},
Apply{
Manager: "two",
Object: `
struct:
string: "string"
`,
APIVersion: "v1",
Conflicts: merge.Conflicts{
merge.Conflict{Manager: "one", Path: _P("struct")},
},
},
ForceApply{
Manager: "two",
Object: `
struct:
string: "string"
`,
APIVersion: "v1",
},
},
Object: `
struct:
string: "string"
`,
APIVersion: "v1",
Managed: fieldpath.ManagedFields{
"two": fieldpath.NewVersionedSet(_NS(
_P("struct"),
), "v1", true),
},
},
"to-atomic-owner-with-no-child-fields": {
Ops: []Operation{
Apply{
Manager: "one",
Object: `
struct:
numeric: 1
`,
APIVersion: "v1",
},
ForceApply{ // take the only child field from manager "one"
Manager: "two",
Object: `
struct:
numeric: 2
`,
APIVersion: "v1",
},
ChangeParser{Parser: structWithAtomicParser},
Apply{
Manager: "three",
Object: `
struct:
string: "string"
`,
APIVersion: "v1",
Conflicts: merge.Conflicts{
// We expect no conflict with "one" because we do not allow a manager
// to own a map without owning any of the children.
merge.Conflict{Manager: "two", Path: _P("struct")},
},
},
ForceApply{
Manager: "two",
Object: `
struct:
string: "string"
`,
APIVersion: "v1",
},
},
Object: `
struct:
string: "string"
`,
APIVersion: "v1",
Managed: fieldpath.ManagedFields{
"two": fieldpath.NewVersionedSet(_NS(
_P("struct"),
), "v1", true),
},
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
if err := test.Test(structParser); err != nil {
t.Fatal(err)
}
})
}
}

func TestAtomicToGranularSchemaChanges(t *testing.T) {
tests := map[string]TestCase{
"to-granular": {
Ops: []Operation{
Apply{
Manager: "one",
Object: `
struct:
numeric: 1
string: "a"
`,
APIVersion: "v1",
},
Apply{
Manager: "two",
Object: `
struct:
string: "b"
`,
APIVersion: "v1",
Conflicts: merge.Conflicts{
merge.Conflict{Manager: "one", Path: _P("struct")},
},
},
ChangeParser{Parser: structParser},
Apply{
Manager: "two",
Object: `
struct:
string: "b"
`,
APIVersion: "v1",
Conflicts: merge.Conflicts{
merge.Conflict{Manager: "one", Path: _P("struct", "string")},
},
},
ForceApply{
Manager: "two",
Object: `
struct:
string: "b"
`,
APIVersion: "v1",
},
},
Object: `
struct:
numeric: 1
string: "b"
`,
APIVersion: "v1",
Managed: fieldpath.ManagedFields{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice!

"one": fieldpath.NewVersionedSet(_NS(
_P("struct"),
_P("struct", "numeric"),
), "v1", true),
"two": fieldpath.NewVersionedSet(_NS(
_P("struct", "string"),
), "v1", true),
},
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
if err := test.Test(structWithAtomicParser); err != nil {
t.Fatal(err)
}
})
}
}
47 changes: 37 additions & 10 deletions merge/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,16 @@ func (s *Updater) update(oldObject, newObject *typed.TypedValue, version fieldpa
// this is a CREATE call).
func (s *Updater) Update(liveObject, newObject *typed.TypedValue, version fieldpath.APIVersion, managers fieldpath.ManagedFields, manager string) (*typed.TypedValue, fieldpath.ManagedFields, error) {
var err error
managers, err = s.reconcileManagedFieldsWithSchemaChanges(liveObject, managers)
if err != nil {
return nil, fieldpath.ManagedFields{}, err
}
if s.enableUnions {
newObject, err = liveObject.NormalizeUnions(newObject)
if err != nil {
return nil, fieldpath.ManagedFields{}, err
}
}
managers = shallowCopyManagers(managers)
managers, compare, err := s.update(liveObject, newObject, version, managers, manager, true)
if err != nil {
return nil, fieldpath.ManagedFields{}, err
Expand Down Expand Up @@ -157,8 +160,11 @@ func (s *Updater) Update(liveObject, newObject *typed.TypedValue, version fieldp
// and return it. If the object hasn't changed, nil is returned (the
// managers can still have changed though).
func (s *Updater) Apply(liveObject, configObject *typed.TypedValue, version fieldpath.APIVersion, managers fieldpath.ManagedFields, manager string, force bool) (*typed.TypedValue, fieldpath.ManagedFields, error) {
managers = shallowCopyManagers(managers)
var err error
managers, err = s.reconcileManagedFieldsWithSchemaChanges(liveObject, managers)
if err != nil {
return nil, fieldpath.ManagedFields{}, err
}
if s.enableUnions {
configObject, err = configObject.NormalizeUnionsApply(configObject)
if err != nil {
Expand Down Expand Up @@ -204,14 +210,6 @@ func (s *Updater) Apply(liveObject, configObject *typed.TypedValue, version fiel
return newObject, managers, nil
}

func shallowCopyManagers(managers fieldpath.ManagedFields) fieldpath.ManagedFields {
newManagers := fieldpath.ManagedFields{}
for manager, set := range managers {
newManagers[manager] = set
}
return newManagers
}

// prune will remove a field, list or map item, iff:
// * applyingManager applied it last time
// * applyingManager didn't apply it this time
Expand Down Expand Up @@ -300,3 +298,32 @@ func (s *Updater) addBackDanglingItems(merged, pruned *typed.TypedValue, lastSet
}
return merged.RemoveItems(mergedSet.Difference(prunedSet).Intersection(lastSet.Set())), nil
}

// reconcileManagedFieldsWithSchemaChanges reconciles the managed fields with any changes to the
// object's schema since the managed fields were written.
//
// Supports:
// - changing types from atomic to granular
// - changing types from granular to atomic
func (s *Updater) reconcileManagedFieldsWithSchemaChanges(liveObject *typed.TypedValue, managers fieldpath.ManagedFields) (fieldpath.ManagedFields, error) {
result := fieldpath.ManagedFields{}
for manager, versionedSet := range managers {
tv, err := s.Converter.Convert(liveObject, versionedSet.APIVersion())
if s.Converter.IsMissingVersionError(err) { // okay to skip, obsolete versions will be deleted automatically anyway
continue
}
if err != nil {
return nil, err
}
reconciled, err := typed.ReconcileFieldSetWithSchema(versionedSet.Set(), tv)
if err != nil {
return nil, err
}
if reconciled != nil {
result[manager] = fieldpath.NewVersionedSet(reconciled, versionedSet.APIVersion(), versionedSet.Applied())
} else {
result[manager] = versionedSet
}
}
return result, nil
}
Loading