diff --git a/go/fn/const.go b/go/fn/const.go index 25e4fce2..1324da77 100644 --- a/go/fn/const.go +++ b/go/fn/const.go @@ -53,6 +53,7 @@ const ( // UnknownNamespace is the special char for cluster-scoped or unknown-scoped resources. This is only used in upstream-identifier UnknownNamespace = "~C" + // DefaultNamespace is the actual namespace value if a namespace-scoped resource has its namespace field unspecified. DefaultNamespace = "default" ) diff --git a/go/fn/go.mod b/go/fn/go.mod index 128d1765..e15485a1 100644 --- a/go/fn/go.mod +++ b/go/fn/go.mod @@ -5,18 +5,18 @@ go 1.24.3 require ( github.com/go-errors/errors v1.5.1 github.com/google/go-cmp v0.7.0 + github.com/kptdev/kpt v1.0.0-beta.57.0.20250625181933-26ae79c92ed2 + github.com/pkg/errors v0.9.1 // indirect github.com/stretchr/testify v1.10.0 + gotest.tools v2.2.0+incompatible k8s.io/apimachinery v0.33.1 // We must not include any core k8s APIs (e.g. k8s.io/api) in // the dependencies, depending on them will likely to cause version skew for // consumers. The dependencies for tests and examples should be isolated. k8s.io/klog/v2 v2.130.1 sigs.k8s.io/kustomize/kyaml v0.19.0 - ) -require github.com/kptdev/kpt v1.0.0-beta.57.0.20250625181933-26ae79c92ed2 - require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-logr/logr v1.4.2 // indirect diff --git a/go/fn/go.sum b/go/fn/go.sum index 0fea075e..6d5dd2e4 100644 --- a/go/fn/go.sum +++ b/go/fn/go.sum @@ -32,6 +32,8 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -82,6 +84,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= k8s.io/apimachinery v0.33.1 h1:mzqXWV8tW9Rw4VeW9rEkqvnxj59k1ezDUl20tFK/oM4= k8s.io/apimachinery v0.33.1/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= diff --git a/go/fn/internal/map.go b/go/fn/internal/map.go index 36482d33..fb24edac 100644 --- a/go/fn/internal/map.go +++ b/go/fn/internal/map.go @@ -46,14 +46,23 @@ type MapVariant struct { node *yaml.Node } -func (o *MapVariant) GetKind() variantKind { - return variantKindMap +func (o *MapVariant) GetKind() VariantKind { + return VariantKindMap } func (o *MapVariant) Node() *yaml.Node { return o.node } +func (o *MapVariant) IsEmpty() bool { + return o == nil || o.node == nil || len(o.node.Content) == 0 +} + +func (o *MapVariant) HasKey(key string) bool { + _, found := o.getVariant(key) + return found +} + func (o *MapVariant) Entries() (map[string]variant, error) { entries := make(map[string]variant) @@ -85,13 +94,6 @@ func (o *MapVariant) Entries() (map[string]variant, error) { return entries, nil } -func asString(node *yaml.Node) (string, bool) { - if node.Kind == yaml.ScalarNode && (node.Tag == "!!str" || node.Tag == "") { - return node.Value, true - } - return "", false -} - func (o *MapVariant) getVariant(key string) (variant, bool) { valueNode, found := getValueNode(o.node, key) if !found { @@ -102,58 +104,31 @@ func (o *MapVariant) getVariant(key string) (variant, bool) { return v, true } -func getValueNode(m *yaml.Node, key string) (*yaml.Node, bool) { - children := m.Content - if len(children)%2 != 0 { - log.Fatalf("unexpected number of children for map %d", len(children)) - } - - for i := 0; i < len(children); i += 2 { - keyNode := children[i] - - k, ok := asString(keyNode) - if ok && k == key { - valueNode := children[i+1] - return valueNode, true - } +func (o *MapVariant) setField(key string, val variant) { + newNode := val.Node() + i := findMapKey(o.node, key) + if i >= 0 { + // update existing field + deepCopyFormatting(o.node.Content[i+1], newNode) + o.node.Content[i+1] = newNode + } else { + // insert new field at the end + o.node.Content = append(o.node.Content, buildStringNode(key), newNode) } - return nil, false } -func (o *MapVariant) set(key string, val variant) { - o.setYAMLNode(key, val.Node()) +func (o *MapVariant) Set(newValue *MapVariant) { + newNode := newValue.Node() + deepCopyFormatting(o.node, newNode) + o.node.Content = newNode.Content } -func (o *MapVariant) setYAMLNode(key string, node *yaml.Node) { - children := o.node.Content - if len(children)%2 != 0 { - log.Fatalf("unexpected number of children for map %d", len(children)) - } - - for i := 0; i < len(children); i += 2 { - keyNode := children[i] - - k, ok := asString(keyNode) - if ok && k == key { - // TODO: Copy comments? - oldNode := children[i+1] - children[i+1] = node - children[i+1].FootComment = oldNode.FootComment - children[i+1].HeadComment = oldNode.HeadComment - children[i+1].LineComment = oldNode.LineComment - return - } - } - - o.node.Content = append(o.node.Content, buildStringNode(key), node) -} - -func (o *MapVariant) remove(key string) (bool, error) { +func (o *MapVariant) remove(key string) bool { removed := false children := o.node.Content if len(children)%2 != 0 { - return false, fmt.Errorf("unexpected number of children for map %d", len(children)) + log.Fatalf("couldn't remove field %q from map node: unexpected odd number of children (%d)", key, len(children)) } var keep []*yaml.Node @@ -171,7 +146,7 @@ func (o *MapVariant) remove(key string) (bool, error) { o.node.Content = keep - return removed, nil + return removed } // remove field metadata.creationTimestamp when it's null. @@ -275,11 +250,26 @@ func (nodes yamlKeyValuePairs) Swap(i, j int) { nodes[i], nodes[j] = nodes[j], n // otherwise it will insert a map at the specified field. // Note that if the value exists but is not a map, it will be replaced with a map. func (o *MapVariant) UpsertMap(field string) *MapVariant { - m := o.GetMap(field) - if m != nil { - return m + node, found := o.getVariant(field) + + if found { + switch node := node.(type) { + case *MapVariant: + // field was found and it is a map + if !node.IsEmpty() { + return node + } + // if the map is empty, replace it with a new map + // this supposed to result in better formatting if the map value is specified as {} + _ = o.remove(field) + + default: + // field was found and it is NOT a map + _ = o.remove(field) + } } + // insert map at field keyNode := &yaml.Node{ Kind: yaml.ScalarNode, Value: field, diff --git a/go/fn/internal/maphelpers.go b/go/fn/internal/maphelpers.go index ead444df..efd40468 100644 --- a/go/fn/internal/maphelpers.go +++ b/go/fn/internal/maphelpers.go @@ -57,7 +57,7 @@ func (o *MapVariant) SetNestedValue(val variant, fields ...string) error { var err error for i := 0; i < n; i++ { if i == n-1 { - current.set(fields[i], val) + current.setField(fields[i], val) } else { current, _, err = current.getMap(fields[i], true) if err != nil { @@ -180,19 +180,19 @@ func (o *MapVariant) SetNestedFloat(f float64, fields ...string) error { return o.SetNestedValue(newFloatScalarVariant(f), fields...) } -func (o *MapVariant) GetNestedSlice(fields ...string) (*sliceVariant, bool, error) { +func (o *MapVariant) GetNestedSlice(fields ...string) (*SliceVariant, bool, error) { node, found, err := o.GetNestedValue(fields...) if err != nil || !found { return nil, found, err } - nodeS, ok := node.(*sliceVariant) + nodeS, ok := node.(*SliceVariant) if !ok { return nil, found, fmt.Errorf("incorrect type, was %T", node) } return nodeS, found, err } -func (o *MapVariant) SetNestedSlice(s *sliceVariant, fields ...string) error { +func (o *MapVariant) SetNestedSlice(s *SliceVariant, fields ...string) error { return o.SetNestedValue(s, fields...) } @@ -206,13 +206,13 @@ func (o *MapVariant) RemoveNestedField(fields ...string) (bool, error) { } if i == n-1 { - return current.remove(fields[i]) + return current.remove(fields[i]), nil } switch entry := entry.(type) { case *MapVariant: current = entry default: - return false, fmt.Errorf("value is of unexpected type %T", entry) + return false, fmt.Errorf("removeNestedField: value is expected to be map, but is of unexpected type %T", entry) } } return false, fmt.Errorf("unexpected code reached") diff --git a/go/fn/internal/scalar.go b/go/fn/internal/scalar.go index 6194b75c..6e371922 100644 --- a/go/fn/internal/scalar.go +++ b/go/fn/internal/scalar.go @@ -32,8 +32,8 @@ type scalarVariant struct { node *yaml.Node } -func (v *scalarVariant) GetKind() variantKind { - return variantKindScalar +func (v *scalarVariant) GetKind() VariantKind { + return VariantKindScalar } func newStringScalarVariant(s string) *scalarVariant { diff --git a/go/fn/internal/slice.go b/go/fn/internal/slice.go index d1cf5cc5..d3672718 100644 --- a/go/fn/internal/slice.go +++ b/go/fn/internal/slice.go @@ -18,34 +18,34 @@ import ( "sigs.k8s.io/kustomize/kyaml/yaml" ) -type sliceVariant struct { +type SliceVariant struct { node *yaml.Node } -func NewSliceVariant(s ...variant) *sliceVariant { +func NewSliceVariant(s ...variant) *SliceVariant { node := buildSequenceNode() for _, v := range s { node.Content = append(node.Content, v.Node()) } - return &sliceVariant{node: node} + return &SliceVariant{node: node} } -func (v *sliceVariant) GetKind() variantKind { - return variantKindSlice +func (v *SliceVariant) GetKind() VariantKind { + return VariantKindSlice } -func (v *sliceVariant) Node() *yaml.Node { +func (v *SliceVariant) Node() *yaml.Node { return v.node } -func (v *sliceVariant) Clear() { +func (v *SliceVariant) Clear() { v.node.Content = nil } -func (v *sliceVariant) Elements() ([]*MapVariant, error) { +func (v *SliceVariant) Elements() ([]*MapVariant, error) { return ExtractObjects(v.node.Content...) } -func (v *sliceVariant) Add(node variant) { +func (v *SliceVariant) Add(node variant) { v.node.Content = append(v.node.Content, node.Node()) } diff --git a/go/fn/internal/variant.go b/go/fn/internal/variant.go index a15447ce..5236266d 100644 --- a/go/fn/internal/variant.go +++ b/go/fn/internal/variant.go @@ -23,16 +23,16 @@ import ( "sigs.k8s.io/kustomize/kyaml/yaml" ) -type variantKind string +type VariantKind string const ( - variantKindMap variantKind = "Map" - variantKindSlice variantKind = "Slice" - variantKindScalar variantKind = "Scalar" + VariantKindMap VariantKind = "Map" + VariantKindSlice VariantKind = "Slice" + VariantKindScalar VariantKind = "Scalar" ) type variant interface { - GetKind() variantKind + GetKind() VariantKind Node() *yaml.Node } @@ -90,7 +90,7 @@ func toVariant(n *yaml.Node) variant { case yaml.MappingNode: return &MapVariant{node: n} case yaml.SequenceNode: - return &sliceVariant{node: n} + return &SliceVariant{node: n} default: panic("unhandled yaml node kind") @@ -150,7 +150,7 @@ func TypedObjectToMapVariant(v interface{}) (*MapVariant, error) { return mv, err } -func TypedObjectToSliceVariant(v interface{}) (*sliceVariant, error) { +func TypedObjectToSliceVariant(v interface{}) (*SliceVariant, error) { // The built-in types only have json tags. We can't simply do ynode.Encode(v), // since it use the lowercased field name by default if no yaml tag is specified. // This affects both k8s built-in types (e.g. appsv1.Deployment) and any types @@ -177,7 +177,7 @@ func TypedObjectToSliceVariant(v interface{}) (*sliceVariant, error) { return nil, fmt.Errorf("unable to convert strong typed object to yaml node: %w", err) } - return &sliceVariant{node: node}, nil + return &SliceVariant{node: node}, nil } func MapVariantToTypedObject(mv *MapVariant, ptr interface{}) error { diff --git a/go/fn/internal/yaml_utils.go b/go/fn/internal/yaml_utils.go new file mode 100644 index 00000000..ba7c0689 --- /dev/null +++ b/go/fn/internal/yaml_utils.go @@ -0,0 +1,217 @@ +// Copyright 2025 kpt and Nephio 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 internal + +import ( + "log" + "slices" + + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +func asString(node *yaml.Node) (string, bool) { + if node.Kind == yaml.ScalarNode && (node.Tag == "!!str" || node.Tag == "") { + return node.Value, true + } + return "", false +} + +func getValueNode(m *yaml.Node, key string) (*yaml.Node, bool) { + children := m.Content + if len(children)%2 != 0 { + log.Fatalf("unexpected number of children for map %d", len(children)) + } + + for i := 0; i < len(children); i += 2 { + keyNode := children[i] + + k, ok := asString(keyNode) + if ok && k == key { + valueNode := children[i+1] + return valueNode, true + } + } + return nil, false +} + +// findMapKey finds `key` in the Content list of a mapping node `node`. +// Returns with the index of the key node if was found, and -1 if not +func findMapKey(node *yaml.Node, key string) int { + return findMapKeyAfter(node, key, 0) +} + +// findMapKey finds `key` in the Content list of a mapping node `node`. +// It starts searching from the `startIdx` position of the nodes' Content. +// Returns with the index of the key node if was found, and -1 if not +func findMapKeyAfter(node *yaml.Node, key string, startIdx int) int { + children := node.Content + if len(children)%2 != 0 { + log.Fatalf("couldn't find field %q in map node: unexpected number of children %d", key, len(children)) + } + + for i := startIdx; i < len(children); i += 2 { + keyNode := children[i] + k, ok := asString(keyNode) + if ok && k == key { + return i + } + } + return -1 +} + +// shallowCopyFormatting copies comments from `src` to `dst` non-recursively +func shallowCopyFormatting(src, dst *yaml.Node) { + dst.Style = src.Style + dst.HeadComment = src.HeadComment + dst.LineComment = src.LineComment + dst.FootComment = src.FootComment + dst.Line = src.Line + dst.Column = src.Column +} + +// deepCopyFormatting copies formatting (comments and order of fields) from `src` to `dst` recursively +func deepCopyFormatting(src, dst *yaml.Node) { + if src.Kind != dst.Kind { + return + } + + switch dst.Kind { + case yaml.MappingNode: + copyMapFormatting(src, dst) + case yaml.SequenceNode, yaml.DocumentNode: + copyListFormatting(src, dst) + case yaml.DocumentNode: + if len(src.Content) == 1 && len(dst.Content) == 1 { + // for single-item document lists always copy formatting + // (not only if the nodes are equal) + shallowCopyFormatting(src, dst) + deepCopyFormatting(src.Content[0], dst.Content[0]) + } else { + // this shouldn't really happen with YAML nodes in KubeObjects + copyListFormatting(src, dst) + } + default: + shallowCopyFormatting(src, dst) + } +} + +// copyMapFormatting copies formatting between MappingNodes recursively +func copyMapFormatting(src, dst *yaml.Node) { + if (len(src.Content)%2 != 0) || (len(dst.Content)%2 != 0) { + log.Fatalf("copy formatting: unexpected number of children of mapping node (%d or %d)", len(src.Content), len(dst.Content)) + } + + // keep comments + shallowCopyFormatting(src, dst) + + // keep ordering: swap dst fields that match src fields to the beginning of dst, in the original order of src fields + newDstIdx := 0 + for srcIdx := 0; srcIdx < len(src.Content); srcIdx += 2 { + key, ok := asString(src.Content[srcIdx]) + if !ok { + continue + } + origDstIdx := findMapKeyAfter(dst, key, newDstIdx) + if origDstIdx < 0 { + continue + } + // swap field to match order in src + if origDstIdx != newDstIdx { + dst.Content[newDstIdx], dst.Content[origDstIdx] = dst.Content[origDstIdx], dst.Content[newDstIdx] + dst.Content[newDstIdx+1], dst.Content[origDstIdx+1] = dst.Content[origDstIdx+1], dst.Content[newDstIdx+1] + } + // copy formatting from the matching src field + shallowCopyFormatting(src.Content[srcIdx], dst.Content[newDstIdx]) + deepCopyFormatting(src.Content[srcIdx+1], dst.Content[newDstIdx+1]) + newDstIdx += 2 + } +} + +// copyListFormatting copies formatting between SequenceNodes recursively. +// List items are handled as atomic types: If an item of `dst` also found +// in `src` (with an exactly matching value), the formatting is copied +// from `src` to `dst`. +func copyListFormatting(src, dst *yaml.Node) { + // keep comments + shallowCopyFormatting(src, dst) + + // shallow copy source list items to a new slice + srcItems := slices.Clone(src.Content) + + for _, dstItem := range dst.Content { + for srcIdx, srcItem := range srcItems { + if nodeDeepEqualValue(srcItem, dstItem) { + // copy formatting from srcItem to dstItem + deepCopyFormatting(srcItem, dstItem) + // remove srcItem from the list + srcItems = slices.Delete(srcItems, srcIdx, srcIdx+1) + break + } + } + } + +} + +// nodeDeepEqualValue returns whether `a` and `b` encode the exact same values (ignores formatting) +func nodeDeepEqualValue(a, b *yaml.Node) bool { + if a.Kind != b.Kind { + return false + } + switch a.Kind { + case yaml.ScalarNode: + return a.Value == b.Value + + case yaml.MappingNode: + if len(a.Content) != len(b.Content) { + return false + } + if len(a.Content)%2 != 0 { + log.Fatalf("unexpected number of children for YAML map") + } + for aIdx := 0; aIdx < len(a.Content); aIdx += 2 { + key, ok := asString(a.Content[aIdx]) + if !ok { + return false + } + bIdx := findMapKey(b, key) + if bIdx < 0 { + return false + } + if !nodeDeepEqualValue(a.Content[aIdx+1], b.Content[bIdx+1]) { + return false + } + } + return true + + case yaml.SequenceNode, yaml.DocumentNode: + if len(a.Content) != len(b.Content) { + return false + } + for i := range a.Content { + if !nodeDeepEqualValue(a.Content[i], b.Content[i]) { + return false + } + } + return true + + case yaml.AliasNode: + // skip Alias nodes + // TODO: check AliasNode properly? + return true + default: + log.Fatalf("unexpected YAML node type: %v", a.Kind) + return false + } +} diff --git a/go/fn/kptfile.go b/go/fn/kptfile.go new file mode 100644 index 00000000..06298a6a --- /dev/null +++ b/go/fn/kptfile.go @@ -0,0 +1,202 @@ +// Copyright 2024 The kpt and Nephio 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 fn + +import ( + "fmt" + "sort" + + kptfileapi "github.com/kptdev/kpt/pkg/api/kptfile/v1" +) + +const ( + statusFieldName = "status" + conditionsFieldName = "conditions" +) + +var ( + BoolToConditionStatus = map[bool]kptfileapi.ConditionStatus{ + true: kptfileapi.ConditionTrue, + false: kptfileapi.ConditionFalse, + } +) + +// Kptfile provides an API to manipulate the Kptfile of a kpt package +type Kptfile struct { + Obj *KubeObject +} + +// NewKptfileFromKubeObjectList creates a KptfileObject by finding it in the given KubeObjects list +func NewKptfileFromKubeObjectList(objs KubeObjects) (*Kptfile, error) { + var ret Kptfile + ret.Obj = objs.GetRootKptfile() + if ret.Obj == nil { + return nil, fmt.Errorf("the Kptfile object is missing from the package") + + } + return &ret, nil +} + +// NewKptfileFromPackage creates a KptfileObject from the resource (YAML) files of a package +func NewKptfileFromPackage(resources map[string]string) (*Kptfile, error) { + kptfileStr, found := resources[kptfileapi.KptFileName] + if !found { + return nil, fmt.Errorf("%s is missing from the package", kptfileapi.KptFileName) + } + + kos, err := ReadKubeObjectsFromFile(kptfileapi.KptFileName, kptfileStr) + if err != nil { + return nil, fmt.Errorf("couldn't parse %s from package: %w", kptfileapi.KptFileName, err) + } + return NewKptfileFromKubeObjectList(kos) +} + +func (kf *Kptfile) WriteToPackage(resources map[string]string) error { + if kf == nil || kf.Obj == nil { + return fmt.Errorf("attempt to write empty Kptfile to the package") + } + kptfileStr, err := WriteKubeObjectsToString(KubeObjects{kf.Obj}) + if err != nil { + return err + } + resources[kptfileapi.KptFileName] = kptfileStr + return nil +} + +func (kf *Kptfile) String() string { + if kf.Obj == nil { + return "" + } + kptfileStr, _ := WriteKubeObjectsToString(KubeObjects{kf.Obj}) + return kptfileStr +} + +// Status returns with the Status field of the Kptfile as a SubObject +// If the Status field doesn't exist, it is added. +func (kf *Kptfile) Status() *SubObject { + return kf.Obj.UpsertMap(statusFieldName) +} + +func (kf *Kptfile) Conditions() SliceSubObjects { + return kf.Status().GetSlice(conditionsFieldName) +} + +func (kf *Kptfile) SetConditions(conditions SliceSubObjects) error { + sort.SliceStable(conditions, func(i, j int) bool { + return conditions[i].GetString("type") < conditions[j].GetString("type") + }) + return kf.Status().SetSlice(conditions, conditionsFieldName) +} + +// TypedConditions returns with (a copy of) the list of current conditions of the kpt package +func (kf *Kptfile) TypedConditions() []kptfileapi.Condition { + statusObj := kf.Obj.GetMap(statusFieldName) + if statusObj == nil { + return nil + } + var status kptfileapi.Status + err := statusObj.As(&status) + if err != nil { + return nil + } + return status.Conditions +} + +// GetTypedCondition returns with the condition whose type is `conditionType` as its first return value, and +// whether the component exists or not as its second return value +func (kf *Kptfile) GetTypedCondition(conditionType string) (kptfileapi.Condition, bool) { + for _, cond := range kf.TypedConditions() { + if cond.Type == conditionType { + return cond, true + } + } + return kptfileapi.Condition{}, false +} + +// SetTypedCondition creates or updates the given condition using the Type field as the primary key +func (kf *Kptfile) SetTypedCondition(condition kptfileapi.Condition) error { + conditions := kf.Conditions() + for _, conditionSubObj := range conditions { + if conditionSubObj.GetString("type") == condition.Type { + // use the SetNestedString methods as opposed to SetNestedStringMap + // in order to keep the order of new fields deterministic + conditionSubObj.SetNestedString(string(condition.Status), "status") + conditionSubObj.SetNestedString(condition.Reason, "reason") + conditionSubObj.SetNestedString(condition.Message, "message") + return kf.SetConditions(conditions) + } + } + ko, err := NewFromTypedObject(condition) + if err != nil { + return err + } + conditions = append(conditions, &ko.SubObject) + return kf.SetConditions(conditions) +} + +// DeleteByTpe deletes all conditions with the given type +func (kf *Kptfile) DeleteConditionByType(conditionType string) error { + oldConditions, found, err := kf.Obj.NestedSlice(conditionsFieldName) + if err != nil { + return err + } + if !found { + return nil + } + newConditions := make([]*SubObject, 0, len(oldConditions)) + for _, c := range oldConditions { + if c.GetString("type") != conditionType { + newConditions = append(newConditions, c) + } + } + return kf.SetConditions(newConditions) +} + +func (kf *Kptfile) AddReadinessGates(gates []kptfileapi.ReadinessGate) error { + info := kf.Obj.UpsertMap("info") + gateObjs := info.GetSlice("readinessGates") + for _, gate := range gates { + // check if readiness gate already exists + found := false + for _, gateObj := range gateObjs { + if gateObj.GetString("conditionType") == gate.ConditionType { + found = true + break + } + } + // add if not found + if !found { + ko, err := NewFromTypedObject(gate) + if err != nil { + return err + } + gateObjs = append(gateObjs, &ko.SubObject) + } + } + info.SetSlice(gateObjs, "readinessGates") + return nil +} + +func (kf *Kptfile) AddMutationFunction(fn *kptfileapi.Function) error { + pipeline := kf.Obj.UpsertMap("pipeline") + mutators := pipeline.GetSlice("mutators") + ko, err := NewFromTypedObject(fn) + if err != nil { + return fmt.Errorf("failed to add mutator function (%s) to Kptfile: %w", fn.Image, err) + } + mutators = append(mutators, &ko.SubObject) + pipeline.SetSlice(mutators, "mutators") + return nil +} diff --git a/go/fn/kptfile/conditions.go b/go/fn/kptfile/conditions.go new file mode 100644 index 00000000..f8b51ddf --- /dev/null +++ b/go/fn/kptfile/conditions.go @@ -0,0 +1,197 @@ +// Copyright 2025 The kpt and Nephio 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 kptfile + +import ( + "fmt" + "slices" + + kptfileapi "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "github.com/kptdev/krm-functions-sdk/go/fn" +) + +type SubObjectMatcher func(obj *fn.SubObject) bool + +// IsType returns with a predicate that is true if the "type" field +// of the given object is equal to the given expectedType +func IsType(expectedType string) SubObjectMatcher { + return func(obj *fn.SubObject) bool { + typeStr, _, _ := obj.NestedString("type") + return typeStr == expectedType + } +} + +// IsTypeAndStatus returns with a predicate that is true if the "type" and "status" fields +// ob the status condition SubObject matches with the given expected values +func IsTypeAndStatus(expectedType string, expectedStatus kptfileapi.ConditionStatus) SubObjectMatcher { + return func(obj *fn.SubObject) bool { + typeStr, _, _ := obj.NestedString("type") + statusStr, _, _ := obj.NestedString("status") + return typeStr == expectedType && statusStr == string(expectedStatus) + } +} + +// IsConditionType returns with a predicate that if the "conditionType" field +// of the given object is equal to the given expectedType +func IsConditionType(expectedType string) SubObjectMatcher { + return func(obj *fn.SubObject) bool { + return obj.GetString("conditionType") == expectedType + } +} + +func (kf *Kptfile) Conditions() fn.SliceSubObjects { + ret, _, _ := kf.NestedSlice("status", "conditions") + return ret +} + +func (kf *Kptfile) SetConditions(conditions fn.SliceSubObjects) error { + return kf.Status().SetSlice(conditions, "conditions") +} + +func (kf *Kptfile) GetCondition(conditionType string) *fn.SubObject { + conditions := kf.Conditions() + i := slices.IndexFunc(conditions, IsType(conditionType)) + if i < 0 { + return nil + } + return conditions[i] +} + +// IsStatusConditionPresentAndEqual returns true when conditionType is present and equal to status. +// Inspired by https://pkg.go.dev/k8s.io/apimachinery/pkg/api/meta#IsStatusConditionPresentAndEqual +func (kf *Kptfile) IsStatusConditionPresentAndEqual(conditionType string, status kptfileapi.ConditionStatus) bool { + conditions := kf.Conditions() + for _, cond := range conditions { + typeStr, _, _ := cond.NestedString("type") + if typeStr == conditionType { + statusStr, _, _ := cond.NestedString("status") + return statusStr == string(status) + } + } + return false +} + +// IsStatusConditionTrue returns true when the conditionType is present and set to kptfileapi.ConditionTrue +// Inspired by https://pkg.go.dev/k8s.io/apimachinery/pkg/api/meta#IsStatusConditionTrue +func (kf *Kptfile) IsStatusConditionTrue(conditionType string) bool { + return kf.IsStatusConditionPresentAndEqual(conditionType, kptfileapi.ConditionTrue) +} + +// IsStatusConditionFalse returns true when the conditionType is present and set to kptfileapi.ConditionFalse +// Inspired by https://pkg.go.dev/k8s.io/apimachinery/pkg/api/meta#IsStatusConditionFalse +func (kf *Kptfile) IsStatusConditionFalse(conditionType string) bool { + return kf.IsStatusConditionPresentAndEqual(conditionType, kptfileapi.ConditionFalse) +} + +// GetTypedCondition returns with the condition whose type is `conditionType` as its first return value, +// or nil if the condition is missing. +func (kf *Kptfile) GetTypedCondition(conditionType string) (*kptfileapi.Condition, error) { + cObj := kf.GetCondition(conditionType) + if cObj == nil { + return nil, nil + } + + var cond kptfileapi.Condition + err := cObj.As(&cond) + if err != nil { + return nil, err + } + return &cond, nil +} + +// SetTypedCondition creates or updates the given condition using the Type field as the primary key +func (kf *Kptfile) SetTypedCondition(condition kptfileapi.Condition) error { + conditions := kf.Conditions() + i := slices.IndexFunc(conditions, IsType(condition.Type)) + if i >= 0 { + // if the condition already exists, update it + // NOTE: use the SetNestedString methods as opposed to SetNestedStringMap + // in order to keep the order of new fields deterministic + conditions[i].SetNestedString(string(condition.Status), "status") + // the "if" prevents a corner case where changing the Reason/Message + // from empty string to empty string and accidentally adding a Reason/Message + // field to the condition that wasn't there originally + if condition.Reason != conditions[i].GetString("reason") { + conditions[i].SetNestedString(condition.Reason, "reason") + } + if condition.Message != conditions[i].GetString("message") { + conditions[i].SetNestedString(condition.Message, "message") + } + } else { + // otherwise, add the condition + ko, err := fn.NewFromTypedObject(condition) + if err != nil { + return fmt.Errorf("failed to set condition %q: %w", condition.Type, err) + } + conditions = append(conditions, &ko.SubObject) + } + return kf.SetConditions(conditions) +} + +// ApplyDefaultCondition adds the given condition to the Kptfile if a condition +// with the same type doesn't exist yet. +func (kf *Kptfile) ApplyDefaultCondition(condition kptfileapi.Condition) error { + conditions := kf.Conditions() + // if condition exists, do nothing + if slices.ContainsFunc(conditions, IsType(condition.Type)) { + return nil + } + + // otherwise, add the condition + ko, err := fn.NewFromTypedObject(condition) + if err != nil { + return fmt.Errorf("failed to apply default condition %q: %w", condition.Type, err) + } + conditions = append(conditions, &ko.SubObject) + return kf.SetConditions(conditions) +} + +// DeleteByTpe deletes all conditions with the given type +func (kf *Kptfile) DeleteConditionByType(conditionType string) error { + conditions := kf.Conditions() + if conditions == nil { + return nil + } + conditions = slices.DeleteFunc(conditions, IsType(conditionType)) + return kf.SetConditions(conditions) +} + +func (kf *Kptfile) ReadinessGates() fn.SliceSubObjects { + ret, _, _ := kf.NestedSlice("info", "readinessGates") + return ret +} + +func (kf *Kptfile) SetReadinessGates(gates fn.SliceSubObjects) error { + return kf.UpsertMap("info").SetSlice(gates, "readinessGates") +} + +// EnsureReadinessGates ensures that the given readiness gates are present in the Kptfile. +func (kf *Kptfile) EnsureReadinessGates(gates []kptfileapi.ReadinessGate) error { + if len(gates) == 0 { + return nil + } + gateObjs := kf.ReadinessGates() + for _, gate := range gates { + if !slices.ContainsFunc(gateObjs, IsConditionType(gate.ConditionType)) { + // if readiness gate is not in list, add it + ko, err := fn.NewFromTypedObject(gate) + if err != nil { + return fmt.Errorf("failed to add readiness gate %s: %w", gate.ConditionType, err) + } + gateObjs = append(gateObjs, &ko.SubObject) + } + } + return kf.SetReadinessGates(gateObjs) +} diff --git a/go/fn/kptfile/kptfile.go b/go/fn/kptfile/kptfile.go new file mode 100644 index 00000000..a0457955 --- /dev/null +++ b/go/fn/kptfile/kptfile.go @@ -0,0 +1,88 @@ +// Copyright 2024 The kpt and Nephio 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 kptfile + +import ( + "fmt" + + kptfileapi "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "github.com/kptdev/krm-functions-sdk/go/fn" +) + +const ( + statusFieldName = "status" +) + +var ( + BoolToConditionStatus = map[bool]kptfileapi.ConditionStatus{ + true: kptfileapi.ConditionTrue, + false: kptfileapi.ConditionFalse, + } +) + +// Kptfile provides an API to manipulate the Kptfile of a kpt package +type Kptfile struct { + // TODO: try to make Kptfile to be also a KubeObject + fn.KubeObject +} + +// NewKptfileFromKubeObjectList creates a KptfileObject by finding it in the given KubeObjects list +func NewFromKubeObjectList(objs fn.KubeObjects) (*Kptfile, error) { + ko := objs.GetRootKptfile() + if ko == nil { + return nil, fmt.Errorf("the Kptfile object is missing from the package") + } + return &Kptfile{KubeObject: *ko}, nil +} + +// NewKptfileFromPackage creates a KptfileObject from the resource (YAML) files of a package +func NewFromPackage(resources map[string]string) (*Kptfile, error) { + kptfileStr, found := resources[kptfileapi.KptFileName] + if !found { + return nil, fmt.Errorf("%s is missing from the package", kptfileapi.KptFileName) + } + + kos, err := fn.ReadKubeObjectsFromFile(kptfileapi.KptFileName, kptfileStr) + if err != nil { + return nil, fmt.Errorf("couldn't parse %s from package: %w", kptfileapi.KptFileName, err) + } + return NewFromKubeObjectList(kos) +} + +func (kf *Kptfile) WriteToPackage(resources map[string]string) error { + if kf == nil { + return fmt.Errorf("attempt to write empty Kptfile to the package") + } + kptfileStr, err := fn.WriteKubeObjectsToString(fn.KubeObjects{&kf.KubeObject}) + if err != nil { + return err + } + resources[kptfileapi.KptFileName] = kptfileStr + return nil +} + +func (kf *Kptfile) String() string { + if kf == nil { + return "" + } + kptfileStr, _ := fn.WriteKubeObjectsToString(fn.KubeObjects{&kf.KubeObject}) + return kptfileStr +} + +// Status returns with the Status field of the Kptfile as a SubObject +// If the Status field doesn't exist, it is added. +func (kf *Kptfile) Status() *fn.SubObject { + return kf.UpsertMap(statusFieldName) +} diff --git a/go/fn/kptfile/kptfile_test.go b/go/fn/kptfile/kptfile_test.go new file mode 100644 index 00000000..4d711f39 --- /dev/null +++ b/go/fn/kptfile/kptfile_test.go @@ -0,0 +1,285 @@ +// Copyright 2024 The kpt and Nephio 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 kptfile + +import ( + "strings" + "testing" + + kptfileapi "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "gotest.tools/assert" +) + +func TestAddCondition(t *testing.T) { + testcases := []struct { + name string + cond *kptfileapi.Condition + resources map[string]string + expectedKptfile string + }{ + { + name: "add condition to missing status", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example + annotations: + config.kubernetes.io/local-config: "true" +pipeline: + mutators: + - image: gcr.io/kpt-fn/set-labels:unstable + configPath: fn-config.yaml`, + + "service.yaml": ` +apiVersion: v1 +kind: Service +metadata: + name: whatever + labels: + app: myApp`, + }, + cond: &kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example + annotations: + config.kubernetes.io/local-config: "true" +pipeline: + mutators: + - image: gcr.io/kpt-fn/set-labels:unstable + configPath: fn-config.yaml +status: + conditions: + - type: test + status: "True" + message: Everything is awesome! + reason: Test`, + }, + { + name: "add condition to empty Kptfile", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example`, + }, + cond: &kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test + status: "True" + message: Everything is awesome! + reason: Test`, + }, + { + name: "add condition to null status field", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status:`, + }, + cond: &kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test + status: "True" + message: Everything is awesome! + reason: Test`, + }, + { + name: "add condition to empty status field", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: {}`, + }, + cond: &kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test + status: "True" + message: Everything is awesome! + reason: Test`, + }, + { + name: "add condition to bad status field", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: bad`, + }, + cond: &kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test + status: "True" + message: Everything is awesome! + reason: Test`, + }, + { + name: "update existing half-empty condition", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test`, + }, + cond: &kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test + status: "True" + reason: Test + message: Everything is awesome!`, + }, + { + name: "updating existing half-empty condition (one line)", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: {conditions: [{type: test}]}`, + }, + cond: &kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: {conditions: [{type: test, status: "True", reason: Test, message: Everything is awesome!}]}`, + }, + { + name: "updating existing condition (one line)", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: {conditions: [{type: test, status: "False", message: Everything is NOT awesome!, reason: TestFailed}]}`, + }, + cond: &kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: {conditions: [{type: test, status: "True", message: Everything is awesome!, reason: Test}]}`, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + kptfile, err := NewFromPackage(tc.resources) + assert.NilError(t, err, "failed to parse Kptfile") + + err = kptfile.SetTypedCondition(*tc.cond) + assert.NilError(t, err, "failed to set condition") + + err = kptfile.WriteToPackage(tc.resources) + assert.NilError(t, err, "failed to write conditions back to Kptfile") + assert.Equal(t, strings.TrimSpace(tc.expectedKptfile), strings.TrimSpace(tc.resources["Kptfile"])) + + gotCond, err := kptfile.GetTypedCondition("test") + assert.NilError(t, err, "condition not found") + assert.Equal(t, *tc.cond, *gotCond, "condition retrieved does not match the expected condition") + }) + } +} diff --git a/go/fn/kptfile/pipeline.go b/go/fn/kptfile/pipeline.go new file mode 100644 index 00000000..6d0b5398 --- /dev/null +++ b/go/fn/kptfile/pipeline.go @@ -0,0 +1,146 @@ +// Copyright 2024 The kpt and Nephio 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 kptfile + +import ( + "fmt" + "reflect" + "slices" + + kptfileapi "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "github.com/kptdev/krm-functions-sdk/go/fn" +) + +// UpsertMutatorFunctions ensures that the given KRM functions are added to or updated in the Kptfile's mutators list. +// If the function already exists, it is updated. If it doesn't exist, it is added at the specified position. +// If insertPosition is negative, the insert position is counted backwards from the end of the list +// (i.e. -1 will append to the list). +func (kf *Kptfile) UpsertMutatorFunctions(fns []kptfileapi.Function, insertPosition int) error { + return kf.UpsertPipelineFunctions(fns, "mutators", insertPosition) +} + +// UpsertValidatorFunctions ensures that the given KRM functions are added to or updated in the Kptfile's validators list. +// If the function already exists, it is updated. If it doesn't exist, it is added at the specified position. +// If insertPosition is negative, the insert position is counted backwards from the end of the list +// (i.e. -1 will append to the list). +func (kf *Kptfile) UpsertValidatorFunctions(fns []kptfileapi.Function, insertPosition int) error { + return kf.UpsertPipelineFunctions(fns, "validators", insertPosition) +} + +// UpsertPipelineFunctions ensures that the given KRM functions are added to or updated in the Kptfile pipeline's given field (`fieldname`). +// `fieldName` should be either "mutators" or "validators". +// If the function already exists, it is updated. If it doesn't exist, it is added at the specified position. +// If insertPosition is negative, the insert position is counted backwards from the end of the list +// (i.e. -1 will append to the list). +func (kf *Kptfile) UpsertPipelineFunctions(fns []kptfileapi.Function, fieldName string, insertPosition int) error { + if len(fns) == 0 { + return nil + } + pipelineKObj := kf.UpsertMap("pipeline") + fnKObjs, _, _ := pipelineKObj.NestedSlice(fieldName) + for _, newKrmFn := range fns { + var err error + var inserted bool + fnKObjs, inserted, err = upsertKrmFunction(fnKObjs, newKrmFn, insertPosition) + if err != nil { + return err + } + if inserted && insertPosition >= 0 { + // if the function was inserted at the beginning, we need to update the insert position + // for the next function + insertPosition++ + } + } + return pipelineKObj.SetSlice(fnKObjs, fieldName) +} + +// upsertKrmFunction ensures that a KRM function is added or updated in the given list of function objects. +// If the function already exists, it is updated. If it doesn't exist, it is added at the specified position. +// If insertPosition is negative, the insert position is counted backwards from the end of the list +// (i.e. -1 will append to the list). +func upsertKrmFunction( + fnKObjs fn.SliceSubObjects, + newKrmFn kptfileapi.Function, + insertPosition int, +) (fn.SliceSubObjects, bool, error) { + + if newKrmFn.Name == "" { + // match by content + fnObj, err := findFunctionByContent(fnKObjs, &newKrmFn) + if err != nil { + return nil, false, err + } + if fnObj != nil { + // function already exists, skip to avoid duplicates + return fnKObjs, false, nil + } + } else { + // match by name + fnObj := findFunctionByName(fnKObjs, newKrmFn.Name) + if fnObj != nil { + // function with the same name exists, update it + var origKrmFn kptfileapi.Function + err := fnObj.As(&origKrmFn) + if err != nil { + return nil, false, fmt.Errorf("failed to parse KRM function from YAML: %w", err) + } + err = fnObj.SetFromTypedObject(newKrmFn) + if err != nil { + return nil, false, fmt.Errorf("failed to update KRM function in Kptfile: %w", err) + } + return fnKObjs, false, nil + } + } + + // function does not exist, insert it + newFuncObj, err := fn.NewFromTypedObject(newKrmFn) + if err != nil { + return nil, false, err + } + if insertPosition < 0 { + insertPosition = len(fnKObjs) + insertPosition + 1 + } + fnKObjs = slices.Insert(fnKObjs, insertPosition, &newFuncObj.SubObject) + return fnKObjs, true, nil +} + +// findFunction returns with the first KRM function in the list with the given name +func findFunctionByName(haystack fn.SliceSubObjects, name string) *fn.SubObject { + for _, fnObj := range haystack { + // match by name + objName, found, _ := fnObj.NestedString("name") + if found && objName == name { + return fnObj + } + } + return nil +} + +// findFunctionByContent returns with the first KRM function in the list that matches the content of the needle +func findFunctionByContent(haystack fn.SliceSubObjects, needle *kptfileapi.Function) (*fn.SubObject, error) { + for _, fnObj := range haystack { + var krmFn kptfileapi.Function + err := fnObj.As(&krmFn) + if err != nil { + return nil, fmt.Errorf("failed to parse KRM function from YAML: %w", err) + } + // ignore diff in name + krmFn.Name = needle.Name + if reflect.DeepEqual(krmFn, needle) { + return fnObj, nil + } + } + return nil, nil +} diff --git a/go/fn/kptfile_test.go b/go/fn/kptfile_test.go new file mode 100644 index 00000000..85e50926 --- /dev/null +++ b/go/fn/kptfile_test.go @@ -0,0 +1,286 @@ +// Copyright 2024 The kpt and Nephio 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 fn + +import ( + "strings" + "testing" + + kptfileapi "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "gotest.tools/assert" +) + +func TestAddCondition(t *testing.T) { + testcases := []struct { + name string + cond kptfileapi.Condition + resources map[string]string + expectedKptfile string + }{ + { + name: "add condition to missing status", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example + annotations: + config.kubernetes.io/local-config: "true" +pipeline: + mutators: + - image: gcr.io/kpt-fn/set-labels:unstable + configPath: fn-config.yaml`, + + "service.yaml": ` +apiVersion: v1 +kind: Service +metadata: + name: whatever + labels: + app: myApp`, + }, + cond: kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example + annotations: + config.kubernetes.io/local-config: "true" +pipeline: + mutators: + - image: gcr.io/kpt-fn/set-labels:unstable + configPath: fn-config.yaml +status: + conditions: + - type: test + status: "True" + message: Everything is awesome! + reason: Test`, + }, + { + name: "add condition to empty Kptfile", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example`, + }, + cond: kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test + status: "True" + message: Everything is awesome! + reason: Test`, + }, + { + name: "add condition to null status field", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status:`, + }, + cond: kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test + status: "True" + message: Everything is awesome! + reason: Test`, + }, + { + name: "add condition to empty status field", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: {}`, + }, + cond: kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test + status: "True" + message: Everything is awesome! + reason: Test`, + }, + { + name: "add condition to bad status field", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: bad`, + }, + cond: kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test + status: "True" + message: Everything is awesome! + reason: Test`, + }, + { + name: "update existing half-empty condition", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test`, + }, + cond: kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: + conditions: + - type: test + status: "True" + reason: Test + message: Everything is awesome!`, + }, + { + name: "updating existing half-empty condition (one line)", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: {conditions: [{type: test}]}`, + }, + cond: kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: {conditions: [{type: test, status: "True", reason: Test, message: Everything is awesome!}]}`, + }, + { + name: "updating existing condition (one line)", + resources: map[string]string{ + "Kptfile": ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: {conditions: [{type: test, status: "False", message: Everything is NOT awesome!, reason: TestFailed}]}`, + }, + cond: kptfileapi.Condition{ + Type: "test", + Status: "True", + Reason: "Test", + Message: "Everything is awesome!", + }, + expectedKptfile: ` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example +status: {conditions: [{type: test, status: "True", message: Everything is awesome!, reason: Test}]}`, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + kptfile, err := NewKptfileFromPackage(tc.resources) + assert.NilError(t, err, "failed to parse Kptfile") + + err = kptfile.SetTypedCondition(tc.cond) + assert.NilError(t, err, "failed to set condition") + + err = kptfile.WriteToPackage(tc.resources) + assert.NilError(t, err, "failed to write conditions back to Kptfile") + assert.Equal(t, strings.TrimSpace(tc.expectedKptfile), strings.TrimSpace(tc.resources["Kptfile"])) + + gotCond, found := kptfile.GetTypedCondition("test") + assert.Equal(t, true, found, "condition not found") + assert.Equal(t, tc.cond, gotCond, "condition retrieved does not match the expected condition") + + }) + } +} diff --git a/go/fn/object.go b/go/fn/object.go index d36898fb..302b7664 100644 --- a/go/fn/object.go +++ b/go/fn/object.go @@ -16,12 +16,10 @@ package fn import ( "fmt" - "math" "reflect" "strconv" "strings" - v1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" "github.com/kptdev/krm-functions-sdk/go/fn/internal" "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/kustomize/kyaml/kio/kioutil" @@ -33,37 +31,6 @@ type KubeObject struct { SubObject } -// ParseKubeObjects parses input byte slice to multiple KubeObjects. -func ParseKubeObjects(in []byte) ([]*KubeObject, error) { - doc, err := internal.ParseDoc(in) - if err != nil { - return nil, fmt.Errorf("failed to parse input bytes: %w", err) - } - objects, err := doc.Elements() - if err != nil { - return nil, fmt.Errorf("failed to extract objects: %w", err) - } - var kubeObjects []*KubeObject - for _, obj := range objects { - kubeObjects = append(kubeObjects, asKubeObject(obj)) - } - return kubeObjects, nil -} - -// ParseKubeObject parses input byte slice to a single KubeObject. -func ParseKubeObject(in []byte) (*KubeObject, error) { - objects, err := ParseKubeObjects(in) - if err != nil { - return nil, err - } - - if len(objects) != 1 { - return nil, fmt.Errorf("expected exactly one object, got %d", len(objects)) - } - obj := objects[0] - return obj, nil -} - // NestedBool returns the bool value, if the field exist and a potential error. func (o *SubObject) NestedBool(fields ...string) (bool, bool, error) { b, found, err := o.obj.GetNestedBool(fields...) @@ -170,7 +137,7 @@ func (o *SubObject) NestedSubObject(fields ...string) (SubObject, bool, error) { return variant, true, nil } -// NestedMap returns a map[string]string value of a nested field, false if not found and an error if not a map[string]string type. +// NestedResource decodes the nested field into a typed object, and returns false if not found and a potential error func (o *SubObject) NestedResource(ptr interface{}, fields ...string) (bool, error) { if ptr == nil || reflect.ValueOf(ptr).Kind() != reflect.Ptr { return false, fmt.Errorf("ptr must be a pointer to an object") @@ -191,7 +158,7 @@ func (o *SubObject) NestedResource(ptr interface{}, fields ...string) (bool, err return true, err } -// NestedMap returns a map[string]string value of a nested field, false if not found and an error if not a map[string]string type. +// NestedStringMap returns a map[string]string value of a nested field, false if not found and an error if not a map[string]string type. func (o *SubObject) NestedStringMap(fields ...string) (map[string]string, bool, error) { var variant map[string]string m, found, err := o.obj.GetNestedMap(fields...) @@ -205,7 +172,7 @@ func (o *SubObject) NestedStringMap(fields ...string) (map[string]string, bool, return variant, found, err } -// NestedStringSlice returns a map[string]string value of a nested field, false if not found and an error if not a map[string]string type. +// NestedStringSlice returns a []string value of a nested field, false if not found and an error if not a []string type. func (o *SubObject) NestedStringSlice(fields ...string) ([]string, bool, error) { var variant []string s, found, err := o.obj.GetNestedSlice(fields...) @@ -347,6 +314,18 @@ func (o *SubObject) SetNestedStringMap(value map[string]string, fields ...string return o.SetNestedField(value, fields...) } +// UpdateNestedStringMap updates the map[string]string `fields` value with the (key, value) pairs in `values`. +// It returns error if the fields type is not map[string]string. +func (o *SubObject) UpdateNestedStringMap(values map[string]string, fields ...string) error { + for field, value := range values { + path := append(fields, field) + if err := o.SetNestedString(value, path...); err != nil { + return fmt.Errorf("couldn't update field %s: %w", strings.Join(fields, "."), err) + } + } + return nil +} + // SetNestedStringSlice sets the `fields` value to []string `value`. It returns error if the fields type is not []string. func (o *SubObject) SetNestedStringSlice(value []string, fields ...string) error { return o.SetNestedField(value, fields...) @@ -435,11 +414,16 @@ func NewFromTypedObject(v interface{}) (*KubeObject, error) { return asKubeObject(m), nil } -// String serializes the object in yaml format. -func (o *SubObject) String() string { +// Bytes serializes the object in yaml format. +func (o *SubObject) Bytes() []byte { doc := internal.NewDoc([]*yaml.Node{o.obj.Node()}...) s, _ := doc.ToYAML() - return string(s) + return s +} + +// String serializes the object in yaml format. +func (o *SubObject) String() string { + return string(o.Bytes()) } // ShortString provides a human readable information for the KubeObject Identifier in the form of GVKNN. @@ -467,6 +451,29 @@ func (o *KubeObject) resourceIdentifier() *yaml.ResourceIdentifier { } } +// A key that uniquely identifies a resource in a package, +// even if there are multiple resources with the same GVKNN. +type PackageScopeUniqueId struct { + yaml.ResourceIdentifier + Path string + Index int +} + +func (id PackageScopeUniqueId) String() string { + return fmt.Sprintf("%s|%s|%s|%s|%s:%d", + id.Kind, id.Name, id.Namespace, id.APIVersion, id.Path, id.Index) +} + +// Returns with a key that uniquely identifies a resource in a package, +// even if there are multiple resources with the same GVKNN. +func (o *KubeObject) GetPackageScopeUniqueId() PackageScopeUniqueId { + return PackageScopeUniqueId{ + ResourceIdentifier: *o.resourceIdentifier(), + Path: o.PathAnnotation(), + Index: o.IndexAnnotation(), + } +} + // GroupVersionKind returns the schema.GroupVersionKind for the specified object. func (o *KubeObject) GroupVersionKind() schema.GroupVersionKind { gv, err := schema.ParseGroupVersion(o.GetAPIVersion()) @@ -482,6 +489,11 @@ func (o *KubeObject) GroupKind() schema.GroupKind { return o.GroupVersionKind().GroupKind() } +// Returns true if the two KubeObjects has the same (Group, Version, Kind, Namespace, Name) +func (o *KubeObject) HasSameId(b *KubeObject) bool { + return *o.resourceIdentifier() == *b.resourceIdentifier() +} + // IsGroupVersionKind compares the given group, version, and kind with KubeObject's apiVersion and Kind. func (o *KubeObject) IsGroupVersionKind(gvk schema.GroupVersionKind) bool { return o.GroupVersionKind() == gvk @@ -556,6 +568,11 @@ func (o *KubeObject) GetNamespace() string { return s } +// GetGKNNString returns with Group, Kind, Namespace, Name in a human readable string +func (o *KubeObject) GetGKNNString() string { + return fmt.Sprintf("%s/%s/%s", o.GroupKind().String(), o.GetNamespace(), o.GetName()) +} + // IsNamespaceScoped tells whether a k8s resource is namespace scoped. If the KubeObject resource is a customized, it // determines the namespace scope by checking whether `metadata.namespace` is set. func (o *KubeObject) IsNamespaceScoped() bool { @@ -658,11 +675,14 @@ func (o *KubeObject) HasLabels(labels map[string]string) bool { } func (o *KubeObject) PathAnnotation() string { - anno := o.GetAnnotation(kioutil.PathAnnotation) - return anno + return o.GetAnnotation(kioutil.PathAnnotation) } -// IndexAnnotation return -1 if not found. +func (o *KubeObject) SetPathAnnotation(path string) error { + return o.SetAnnotation(kioutil.PathAnnotation, path) +} + +// IndexAnnotation returns -1 if not found. func (o *KubeObject) IndexAnnotation() int { anno := o.GetAnnotation(kioutil.IndexAnnotation) if anno == "" { @@ -672,6 +692,10 @@ func (o *KubeObject) IndexAnnotation() int { return i } +func (o *KubeObject) SetIndexAnnotation(index int) error { + return o.SetAnnotation(kioutil.IndexAnnotation, strconv.Itoa(index)) +} + // IdAnnotation return -1 if not found. func (o *KubeObject) IdAnnotation() int { anno := o.GetAnnotation(kioutil.IdAnnotation) @@ -683,119 +707,6 @@ func (o *KubeObject) IdAnnotation() int { return i } -type KubeObjects []*KubeObject - -func (o KubeObjects) Len() int { return len(o) } -func (o KubeObjects) Swap(i, j int) { o[i], o[j] = o[j], o[i] } -func (o KubeObjects) Less(i, j int) bool { - idi := o[i].resourceIdentifier() - idj := o[j].resourceIdentifier() - idStrI := fmt.Sprintf("%s %s %s %s", idi.GetAPIVersion(), idi.GetKind(), idi.GetNamespace(), idi.GetName()) - idStrJ := fmt.Sprintf("%s %s %s %s", idj.GetAPIVersion(), idj.GetKind(), idj.GetNamespace(), idj.GetName()) - return idStrI < idStrJ -} - -func (o KubeObjects) String() string { - var elems []string - for _, obj := range o { - elems = append(elems, strings.TrimSpace(obj.String())) - } - return strings.Join(elems, "\n---\n") -} - -// Where will return the subset of objects in KubeObjects such that f(object) returns 'true'. -func (o KubeObjects) Where(f func(*KubeObject) bool) KubeObjects { - var result KubeObjects - for _, obj := range o { - if f(obj) { - result = append(result, obj) - } - } - return result -} - -// Not returns will return a function that returns the opposite of f(object), i.e. !f(object) -func Not(f func(*KubeObject) bool) func(o *KubeObject) bool { - return func(o *KubeObject) bool { - return !f(o) - } -} - -// WhereNot will return the subset of objects in KubeObjects such that f(object) returns 'false'. -// This is a shortcut for Where(Not(f)). -func (o KubeObjects) WhereNot(f func(o *KubeObject) bool) KubeObjects { - return o.Where(Not(f)) -} - -// IsGVK returns a function that checks if a KubeObject has a certain GVK. -// Deprecated: Prefer exact matching with IsGroupVersionKind or IsGroupKind -func IsGVK(group, version, kind string) func(*KubeObject) bool { - return func(o *KubeObject) bool { - return o.IsGVK(group, version, kind) - } -} - -// IsGroupVersionKind returns a function that checks if a KubeObject has a certain GroupVersionKind. -func IsGroupVersionKind(gvk schema.GroupVersionKind) func(*KubeObject) bool { - return func(o *KubeObject) bool { - return o.IsGroupVersionKind(gvk) - } -} - -// IsGroupKind returns a function that checks if a KubeObject has a certain GroupKind. -func IsGroupKind(gk schema.GroupKind) func(*KubeObject) bool { - return func(o *KubeObject) bool { - return o.IsGroupKind(gk) - } -} - -// GetRootKptfile returns the root Kptfile. Nested kpt packages can have multiple Kptfile files of the same GVKNN. -func (o KubeObjects) GetRootKptfile() *KubeObject { - kptfiles := o.Where(IsGVK(v1.KptFileGroup, v1.KptFileVersion, v1.KptFileKind)) - if len(kptfiles) == 0 { - return nil - } - minDepths := math.MaxInt32 - var rootKptfile *KubeObject - for _, kf := range kptfiles { - path := kf.GetAnnotation(PathAnnotation) - depths := len(strings.Split(path, "/")) - if depths <= minDepths { - minDepths = depths - rootKptfile = kf - } - } - return rootKptfile -} - -// IsName returns a function that checks if a KubeObject has a certain name. -func IsName(name string) func(*KubeObject) bool { - return func(o *KubeObject) bool { - return o.GetName() == name - } -} - -// IsNamespace returns a function that checks if a KubeObject has a certain namespace. -func IsNamespace(namespace string) func(*KubeObject) bool { - return func(o *KubeObject) bool { - return o.GetNamespace() == namespace - } -} - -// HasLabels returns a function that checks if a KubeObject has all the given labels. -func HasLabels(labels map[string]string) func(*KubeObject) bool { - return func(o *KubeObject) bool { - return o.HasLabels(labels) - } -} - -// HasAnnotations returns a function that checks if a KubeObject has all the given annotations. -func HasAnnotations(annotations map[string]string) func(*KubeObject) bool { - return func(o *KubeObject) bool { - return o.HasAnnotations(annotations) - } -} - // IsMetaResource returns a function that checks if a KubeObject is a meta resource. For now // this just includes the Kptfile func IsMetaResource() func(*KubeObject) bool { @@ -828,6 +739,53 @@ func rnodeToKubeObject(rn *yaml.RNode) *KubeObject { return asKubeObject(mapVariant) } +func NewKubeObjectFromMap(m map[string]interface{}) (*KubeObject, error) { + rn, err := yaml.FromMap(m) + if err != nil { + return nil, fmt.Errorf("couldn't convert unstructured/JSON map to KubeObject: %w", err) + } + return rnodeToKubeObject(rn), nil +} + +// NewKubeObjectFromResourceNode creates a KubeObject from the deep copy of a yaml.RNode +func NewKubeObjectFromResourceNode(rn *yaml.RNode) *KubeObject { + // create a deep copy of the RNode to avoid exposing internal state of the new KubeObject + return rnodeToKubeObject(rn.Copy()) +} + +// CopyToResourceNode returns a deep copy of the KubeObject's internal yaml.RNode +func (o *KubeObject) CopyToResourceNode() *yaml.RNode { + return yaml.NewRNode(o.obj.Node()).Copy() +} + +// MoveToResourceNode transfers the ownership of the internal yaml nodes of the KubeObject +// into a new yaml.RNode, and leaves the original KubeObject empty. +func (o *KubeObject) MoveToResourceNode() *yaml.RNode { + ynode := o.obj.Node() + o.SubObject = NewEmptyKubeObject().SubObject + return yaml.NewRNode(ynode) +} + +// CopyToKubeObject makes a copy of the internal yaml nodes of the RNode into a new KubeObject. +func CopyToKubeObject(rn *yaml.RNode) *KubeObject { + return rnodeToKubeObject(rn.Copy()) +} + +// MoveToKubeObject transfers the ownership of the internal yaml nodes of the RNode +// into a new KubeObject, and leaves the original RNode empty. +func MoveToKubeObject(rn *yaml.RNode) *KubeObject { + ret := rnodeToKubeObject(rn) + *rn = *yaml.MakeNullNode() + return ret +} + +// Copy returns a deep copy of the KubeObject +func (o *KubeObject) Copy() *KubeObject { + ynode := yaml.CopyYNode(o.obj.Node()) + mapVariant := internal.NewMap(ynode) + return &KubeObject{SubObject{parentGVK: o.parentGVK, obj: mapVariant, fieldpath: ""}} +} + // SubObject represents a map within a KubeObject type SubObject struct { parentGVK schema.GroupVersionKind @@ -835,11 +793,52 @@ type SubObject struct { obj *internal.MapVariant } +func (o *SubObject) IsEmpty() bool { + return o == nil || o.obj.IsEmpty() +} + +func (o *SubObject) HasField(key string) bool { + return o.obj.HasKey(key) +} + func (o *SubObject) UpsertMap(k string) *SubObject { m := o.obj.UpsertMap(k) return &SubObject{obj: m, parentGVK: o.parentGVK, fieldpath: o.fieldpath + "." + k} } +// SetFromTypedObject ensures that the value of `o` (this object) is the same as `newValue“, +// while keeps the formatting of the original object. +func (o *SubObject) Set(newValue *SubObject) error { + o.obj.Set(newValue.obj) + return nil +} + +// SetFromTypedObject ensures that the value of `o` (this object) is the same as `newValue“, +// while keeps the formatting of the original object. +// `newValue` must be of type struct or map[string]... +func (o *SubObject) SetFromTypedObject(newValue any) error { + kind := reflect.ValueOf(newValue).Kind() + if kind == reflect.Ptr { + kind = reflect.TypeOf(newValue).Elem().Kind() + } + if kind != reflect.Struct && kind != reflect.Map { + return fmt.Errorf("expected struct or map, got %T", newValue) + } + + newMap, err := internal.TypedObjectToMapVariant(newValue) + if err != nil { + return err + } + o.obj.Set(newMap) + return nil +} + +// SetMap accepts a single key `k`, and ensures that the value of `k` is the same as the map it received +// via `mapObject` in the form of a SubObject pointer. +func (o *SubObject) SetMap(mapObj *SubObject, k string) error { + return o.obj.SetNestedValue(mapObj.obj, k) +} + // GetMap accepts a single key `k` whose value is expected to be a map. It returns // the map in the form of a SubObject pointer. // It panic with ErrSubObjectFields error if the field cannot be represented as a SubObject. diff --git a/go/fn/object_io.go b/go/fn/object_io.go new file mode 100644 index 00000000..03d9b338 --- /dev/null +++ b/go/fn/object_io.go @@ -0,0 +1,191 @@ +// Copyright 2024 The kpt and Nephio 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. + +// this code is based on https://github.com/nephio-project/porch/blob/main/pkg/engine/kio.go + +package fn + +import ( + "bytes" + "fmt" + "path" + "path/filepath" + "strings" + + kptfilev1 "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "github.com/kptdev/krm-functions-sdk/go/fn/internal" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/kio/kioutil" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +// ParseKubeObjects parses input byte slice to multiple KubeObjects. +func ParseKubeObjects(in []byte) ([]*KubeObject, error) { + doc, err := internal.ParseDoc(in) + if err != nil { + return nil, fmt.Errorf("failed to parse input bytes: %w", err) + } + objects, err := doc.Elements() + if err != nil { + return nil, fmt.Errorf("failed to extract objects: %w", err) + } + var kubeObjects []*KubeObject + for _, obj := range objects { + kubeObjects = append(kubeObjects, asKubeObject(obj)) + } + return kubeObjects, nil +} + +// ParseKubeObject parses input byte slice to a single KubeObject. +func ParseKubeObject(in []byte) (*KubeObject, error) { + objects, err := ParseKubeObjects(in) + if err != nil { + return nil, err + } + + if len(objects) != 1 { + return nil, fmt.Errorf("expected exactly one object, got %d", len(objects)) + } + obj := objects[0] + return obj, nil +} + +func ReadKubeObjectsFromDirectory(path string) (KubeObjects, error) { + reader := &kio.LocalPackageReader{ + PackagePath: path, + PackageFileName: kptfilev1.KptFileName, + MatchFilesGlob: MatchAllKRM, + IncludeSubpackages: true, + ErrorIfNonResources: false, + OmitReaderAnnotations: false, + PreserveSeqIndent: true, + } + rnodes, err := reader.Read() + if err != nil { + return nil, fmt.Errorf("failed to read KubeObjects from directory %q: %w", path, err) + } + kobjs := make([]*KubeObject, len(rnodes)) + for i := range rnodes { + kobjs[i] = MoveToKubeObject(rnodes[i]) + } + return kobjs, nil +} + +func ReadKubeObjectsFromPackage(inputFiles map[string]string) (objs KubeObjects, extraFiles map[string]string, err error) { + extraFiles = make(map[string]string) + for path, content := range inputFiles { + if !IsKrmResourceFile(path) { + extraFiles[path] = content + continue + } + fileObjs, err := ReadKubeObjectsFromFile(path, content) + if err != nil { + return nil, nil, err + } + objs = append(objs, fileObjs...) + } + return +} + +func ReadKubeObjectsFromFile(filepath string, content string) (KubeObjects, error) { + reader := &kio.ByteReader{ + Reader: strings.NewReader(content), + SetAnnotations: map[string]string{ + kioutil.PathAnnotation: filepath, + }, + DisableUnwrapping: true, + // need to preserve indentation to avoid Git conflicts in written-out YAML + PreserveSeqIndent: true, + } + nodes, err := reader.Read() + if err != nil { + // TODO: fail, or bypass this file too? + return nil, err + } + objs := KubeObjects{} + for _, node := range nodes { + objs = append(objs, MoveToKubeObject(node)) + } + return objs, nil +} + +func WriteKubeObjectsToPackage(objs KubeObjects) (map[string]string, error) { + output := map[string]string{} + paths := map[string][]*KubeObject{} + for _, obj := range objs { + path := PathOfKubeObject(obj) + paths[path] = append(paths[path], obj) + } + + var err error + for path, objs := range paths { + output[path], err = WriteKubeObjectsToString(objs) + if err != nil { + return nil, err + } + } + return output, nil +} + +func WriteKubeObjectsToString(objs KubeObjects) (string, error) { + buf := &bytes.Buffer{} + bw := kio.ByteWriter{ + Writer: buf, + ClearAnnotations: []string{ + kioutil.PathAnnotation, + kioutil.LegacyPathAnnotation, + }, + } + + nodes := []*yaml.RNode{} + for _, obj := range objs { + nodes = append(nodes, obj.CopyToResourceNode()) + } + if err := bw.Write(nodes); err != nil { + return "", err + } + return buf.String(), nil +} + +// PathOfKubeObject returns the path of a KubeObject within a package +// By default is uses the PathAnnotation, otherwise it returns with a default path based on the namespace and name of the object +func PathOfKubeObject(node *KubeObject) string { + pathAnno := node.PathAnnotation() + if pathAnno != "" { + return pathAnno + } + ns := node.GetNamespace() + if ns == "" { + ns = "no-namespace" + } + name := node.GetName() + if name == "" { + name = "unnamed" + } + return path.Join(ns, fmt.Sprintf("%s.yaml", name)) +} + +var MatchAllKRM = append([]string{kptfilev1.KptFileName}, kio.MatchAll...) + +// IsKrmResourceFile checks if a file in a kpt package should be parsed for KRM resources +func IsKrmResourceFile(path string) bool { + // Only use the filename for the check for whether we should include the file. + filename := filepath.Base(path) + for _, m := range MatchAllKRM { + if matched, err := filepath.Match(m, filename); err == nil && matched { + return true + } + } + return false +} diff --git a/go/fn/object_test.go b/go/fn/object_test.go index 80375b07..e28d20fc 100644 --- a/go/fn/object_test.go +++ b/go/fn/object_test.go @@ -335,7 +335,10 @@ kind: ResourceList `) func TestNilFnConfigResourceList(t *testing.T) { - rl, _ := ParseResourceList(noFnConfigResourceList) + rl, err := ParseResourceList(noFnConfigResourceList) + if err != nil { + t.Fatalf("Error parsing resource list: %v", err) + } if rl.FunctionConfig == nil { t.Errorf("Empty functionConfig in ResourceList should still be initialized to avoid nil pointer error") } @@ -370,7 +373,6 @@ func TestNilFnConfigResourceList(t *testing.T) { t.Errorf("Nil KubeObject shall not have the field path `not-exist` exist, and not expect errors") } } - var err error // Check that nil FunctionConfig should be editable. { err = rl.FunctionConfig.SetKind("CustomFn") @@ -509,10 +511,10 @@ func TestSetNestedFields(t *testing.T) { if stringMapVal, _, _ := o.NestedString("tags", "tag2"); stringMapVal != "test1" { t.Errorf("KubeObject .tags.tag2 expected to get `test1`, got %v", stringMapVal) } - err = o.SetNestedStringSlice([]string{"lable1", "lable2"}, "labels") + err = o.SetNestedStringSlice([]string{"label1", "label2"}, "labels") assert.NoError(t, err) - if stringSliceVal, _, _ := o.NestedStringSlice("labels"); !reflect.DeepEqual(stringSliceVal, []string{"lable1", "lable2"}) { - t.Errorf("KubeObject .labels expected to get [`lable1`, `lable2`], got %v", stringSliceVal) + if stringSliceVal, _, _ := o.NestedStringSlice("labels"); !reflect.DeepEqual(stringSliceVal, []string{"label1", "label2"}) { + t.Errorf("KubeObject .labels expected to get [`label1`, `label2`], got %v", stringSliceVal) } } @@ -854,3 +856,150 @@ metadata: t.Fatalf("unexpected result from GroupVersionKind(); got %v; want %v", got, want) } } + +func TestRNodeInteroperability(t *testing.T) { + input := []byte(` +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: my-app + namespace: my-ns +`) + + var found bool + ko, err := ParseKubeObject(input) + if err != nil { + t.Fatalf("failed to parse object: %v", err) + } + + // test copy to and from ResourceNode + rn := ko.CopyToResourceNode() + assert.Equal(t, "apps/v1", ko.GetAPIVersion()) + assert.Equal(t, "StatefulSet", ko.GetKind()) + assert.Equal(t, "my-app", ko.GetName()) + assert.Equal(t, "my-ns", ko.GetNamespace()) + assert.Equal(t, "apps/v1", rn.GetApiVersion()) + assert.Equal(t, "StatefulSet", rn.GetKind()) + assert.Equal(t, "my-app", rn.GetName()) + assert.Equal(t, "my-ns", rn.GetNamespace()) + + ko2 := NewKubeObjectFromResourceNode(rn) + assert.Equal(t, "apps/v1", rn.GetApiVersion()) + assert.Equal(t, "StatefulSet", rn.GetKind()) + assert.Equal(t, "my-app", rn.GetName()) + assert.Equal(t, "my-ns", rn.GetNamespace()) + assert.Equal(t, "apps/v1", ko2.GetAPIVersion()) + assert.Equal(t, "StatefulSet", ko2.GetKind()) + assert.Equal(t, "my-app", ko2.GetName()) + assert.Equal(t, "my-ns", ko2.GetNamespace()) + + // test move to and from ResourceNode + rn2 := ko2.MoveToResourceNode() + _, found, _ = ko2.NestedString("apiVersion") + assert.False(t, found) + _, found, _ = ko2.NestedString("kind") + assert.False(t, found) + _, found, _ = ko2.NestedString("metadata", "name") + assert.False(t, found) + _, found, _ = ko2.NestedString("metadata", "namespace") + assert.False(t, found) + assert.Equal(t, "apps/v1", rn2.GetApiVersion()) + assert.Equal(t, "StatefulSet", rn2.GetKind()) + assert.Equal(t, "my-app", rn2.GetName()) + assert.Equal(t, "my-ns", rn2.GetNamespace()) + + ko3 := MoveToKubeObject(rn2) + assert.Equal(t, "apps/v1", ko3.GetAPIVersion()) + assert.Equal(t, "StatefulSet", ko3.GetKind()) + assert.Equal(t, "my-app", ko3.GetName()) + assert.Equal(t, "my-ns", ko3.GetNamespace()) + assert.Empty(t, rn2.GetApiVersion()) + assert.Empty(t, rn2.GetKind()) + assert.Empty(t, rn2.GetName()) + assert.Empty(t, rn2.GetNamespace()) +} + +func TestDeepCopy(t *testing.T) { + input := []byte(` +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: my-app + namespace: my-ns +`) + + orig, err := ParseKubeObject(input) + if err != nil { + t.Fatalf("failed to parse object: %v", err) + } + + copy := orig.Copy() + assert.Equal(t, orig.String(), copy.String()) + copy.SetName("new-name") + assert.Equal(t, "my-app", orig.GetName()) + assert.Equal(t, "new-name", copy.GetName()) +} + +func TestUpsert(t *testing.T) { + resources := []byte(` +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: example + annotations: + config.kubernetes.io/local-config: "true" +pipeline: + mutators: + - image: gcr.io/kpt-fn/set-labels:unstable + configPath: fn-config.yaml +--- +apiVersion: v1 +kind: Service +metadata: + name: whatever + labels: + app: myApp +`) + + toUpdate := []byte(` +apiVersion: v1 +kind: Service +metadata: + name: whatever + labels: + app: notMyApp +`) + + toInsert := []byte(` +apiVersion: v1 +kind: Service +metadata: + name: new + labels: + app: notMyApp +`) + + var objs KubeObjects + objs, err := ParseKubeObjects(resources) + if err != nil { + t.Fatalf("failed to parse objects: %v", err) + } + + toUpdateObj, err := ParseKubeObject(toUpdate) + if err != nil { + t.Fatalf("failed to parse object: %v", err) + } + objs.Upsert(toUpdateObj) + assert.Equal(t, len(objs), 2) + assert.Equal(t, "whatever", objs[1].GetMap("metadata").GetString("name")) + assert.Equal(t, "notMyApp", objs[1].GetMap("metadata").GetMap("labels").GetString("app")) + + toInsertObj, err := ParseKubeObject(toInsert) + if err != nil { + t.Fatalf("failed to parse object: %v", err) + } + objs.Upsert(toInsertObj) + assert.Equal(t, len(objs), 3) + assert.Equal(t, "new", objs[2].GetMap("metadata").GetString("name")) + assert.Equal(t, "notMyApp", objs[2].GetMap("metadata").GetMap("labels").GetString("app")) +} diff --git a/go/fn/objects.go b/go/fn/objects.go new file mode 100644 index 00000000..3bcadaeb --- /dev/null +++ b/go/fn/objects.go @@ -0,0 +1,225 @@ +// Copyright 2022 The kpt 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 fn + +import ( + "fmt" + "math" + "strings" + + kptfile "github.com/kptdev/kpt/pkg/api/kptfile/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +type KubeObjects []*KubeObject + +func (kos KubeObjects) Len() int { return len(kos) } +func (kos KubeObjects) Swap(i, j int) { kos[i], kos[j] = kos[j], kos[i] } +func (kos KubeObjects) Less(i, j int) bool { + idi := kos[i].resourceIdentifier() + idj := kos[j].resourceIdentifier() + idStrI := fmt.Sprintf("%s %s %s %s", idi.GetAPIVersion(), idi.GetKind(), idi.GetNamespace(), idi.GetName()) + idStrJ := fmt.Sprintf("%s %s %s %s", idj.GetAPIVersion(), idj.GetKind(), idj.GetNamespace(), idj.GetName()) + return idStrI < idStrJ +} + +func (kos KubeObjects) String() string { + var elems []string + for _, obj := range kos { + elems = append(elems, strings.TrimSpace(obj.String())) + } + return strings.Join(elems, "\n---\n") +} + +// EnsureSingleItem checks if KubeObjects contains exactly one item and returns it, or an error if it doesn't. +func (kos KubeObjects) EnsureSingleItem() (*KubeObject, error) { + if len(kos) == 0 || len(kos) > 1 { + return nil, fmt.Errorf("%v objects found, but exactly 1 is expected", len(kos)) + } + return kos[0], nil +} + +func (kos KubeObjects) EnsureSingleItemAs(out any) error { + obj, err := kos.EnsureSingleItem() + if err != nil { + return err + } + return obj.As(out) +} + +// Upsert updates or insert the given KubeObject into the list +// If the list contains an object with the same (Group, Version, Kind, Namespace, Name), then Upsert replaces it with `newObj`, +// otherwise it appends `newObj` to the list +func (kos *KubeObjects) Upsert(newObj *KubeObject) { + + for i, kobj := range *kos { + if newObj.HasSameId(kobj) { + (*kos)[i] = newObj + return + } + } + *kos = append(*kos, newObj) +} + +// UpsertTypedObject attempts to convert `newObj` to a KubeObject and then calls Upsert(). +func (kos *KubeObjects) UpsertTypedObject(newObj any) error { + if newObj == nil { + return fmt.Errorf("obj is nil") + } + + newKubeObj, err := NewFromTypedObject(newObj) + if err != nil { + return err + } + + kos.Upsert(newKubeObj) + return nil +} + +// Where will return the subset of objects in KubeObjects such that f(object) returns 'true'. +func (kos KubeObjects) Where(f func(*KubeObject) bool) KubeObjects { + var result KubeObjects + for _, obj := range kos { + if f(obj) { + result = append(result, obj) + } + } + return result +} + +// Not returns will return a function that returns the opposite of f(object), i.e. !f(object) +func Not(f func(*KubeObject) bool) func(o *KubeObject) bool { + return func(o *KubeObject) bool { + return !f(o) + } +} + +// WhereNot will return the subset of objects in KubeObjects such that f(object) returns 'false'. +// This is a shortcut for Where(Not(f)). +func (kos KubeObjects) WhereNot(f func(o *KubeObject) bool) KubeObjects { + return kos.Where(Not(f)) +} + +// Split separates the KubeObjects based on whether the predicate is true or false for them. +func (kos KubeObjects) Split(predicate func(o *KubeObject) bool) (KubeObjects, KubeObjects) { + tru, fals := KubeObjects{}, KubeObjects{} + for _, obj := range kos { + if predicate(obj) { + tru = append(tru, obj) + } else { + fals = append(fals, obj) + } + } + return tru, fals +} + +// SetAnnotation sets the specified annotation for all KubeObjects in the slice +func (kos KubeObjects) SetAnnotation(key, value string) error { + for _, ko := range kos { + if err := ko.SetAnnotation(key, value); err != nil { + return err + } + } + return nil +} + +// IsGVK returns a function that checks if a KubeObject has a certain GVK. +// Deprecated: Prefer exact matching with IsGroupVersionKind or IsGroupKind +func IsGVK(group, version, kind string) func(*KubeObject) bool { + return func(o *KubeObject) bool { + return o.IsGVK(group, version, kind) + } +} + +// IsGroupVersionKind returns a function that checks if a KubeObject has a certain GroupVersionKind. +func IsGroupVersionKind(gvk schema.GroupVersionKind) func(*KubeObject) bool { + return func(o *KubeObject) bool { + return o.IsGroupVersionKind(gvk) + } +} + +// IsGroupKind returns a function that checks if a KubeObject has a certain GroupKind. +func IsGroupKind(gk schema.GroupKind) func(*KubeObject) bool { + return func(o *KubeObject) bool { + return o.IsGroupKind(gk) + } +} + +// GetRootKptfile returns the root Kptfile. Nested kpt packages can have multiple Kptfile files of the same GVKNN. +func (kos KubeObjects) GetRootKptfile() *KubeObject { + kptfiles := kos.Where(IsGroupVersionKind(kptfile.KptFileGVK())) + if len(kptfiles) == 0 { + return nil + } + minDepths := math.MaxInt32 + var rootKptfile *KubeObject + for _, kf := range kptfiles { + path := kf.PathAnnotation() + depths := len(strings.Split(path, "/")) + if depths <= minDepths { + minDepths = depths + rootKptfile = kf + } + } + return rootKptfile +} + +// IsName returns a function that checks if a KubeObject has a certain name. +func IsName(name string) func(*KubeObject) bool { + return func(o *KubeObject) bool { + return o.GetName() == name + } +} + +// IsNamespace returns a function that checks if a KubeObject has a certain namespace. +func IsNamespace(namespace string) func(*KubeObject) bool { + return func(o *KubeObject) bool { + return o.GetNamespace() == namespace + } +} + +// HasLabels returns a function that checks if a KubeObject has all the given labels. +func HasLabels(labels map[string]string) func(*KubeObject) bool { + return func(o *KubeObject) bool { + return o.HasLabels(labels) + } +} + +// HasAnnotations returns a function that checks if a KubeObject has all the given annotations. +func HasAnnotations(annotations map[string]string) func(*KubeObject) bool { + return func(o *KubeObject) bool { + return o.HasAnnotations(annotations) + } +} + +// MoveToKubeObjects moves all yaml.RNodes into KubeObjects, leaving the original slice with empty nodes +func MoveToKubeObjects(rns []*yaml.RNode) KubeObjects { + var output KubeObjects + for i := range rns { + output = append(output, MoveToKubeObject(rns[i])) + } + return output +} + +// CopyToResourceNodes copies the entire KubeObjects slice to yaml.RNodes +func (kos KubeObjects) CopyToResourceNodes() kio.ResourceNodeSlice { + var output kio.ResourceNodeSlice + for i := range kos { + output = append(output, kos[i].CopyToResourceNode()) + } + return output +} diff --git a/go/fn/resourcelist.go b/go/fn/resourcelist.go index a2f4f591..921aa728 100644 --- a/go/fn/resourcelist.go +++ b/go/fn/resourcelist.go @@ -111,13 +111,25 @@ func ParseResourceList(in []byte) (*ResourceList, error) { } // Parse Results. Results can be empty. - res, found, err := rlObj.obj.GetNestedSlice("results") + res, found, err := rlObj.obj.GetNestedValue("results") if err != nil { - return nil, fmt.Errorf("failed when tried to get results: %w", err) + return nil, fmt.Errorf("failed when trying to get results: %w", err) } + + var resultsItems *internal.SliceVariant + // compatibility between kyaml versions + if m, ok := res.(*internal.MapVariant); ok { + resultsItems, found, err = m.GetNestedSlice("items") + } else if resultsItems, ok = res.(*internal.SliceVariant); !ok { + // no results + } + if err != nil { + return nil, fmt.Errorf("failed when trying to get results: %w", err) + } + if found { var results Results - err = res.Node().Decode(&results) + err = resultsItems.Node().Decode(&results) if err != nil { return nil, fmt.Errorf("failed to decode results: %w", err) } diff --git a/go/fn/io.go b/go/fn/resourcelist_io.go similarity index 100% rename from go/fn/io.go rename to go/fn/resourcelist_io.go