diff --git a/go/Makefile b/go/Makefile index 2adfedfd..5e9f0cf2 100644 --- a/go/Makefile +++ b/go/Makefile @@ -6,11 +6,6 @@ GOBIN := $(shell go env GOPATH)/bin OUT_DIR := .out MODULES = $(shell find . -name 'go.mod' -print) -.PHONY: api -api: - (which swagger || go install github.com/go-swagger/go-swagger/cmd/swagger@v0.27.0) - $(GOPATH)/bin/swagger generate spec -m -w api/kptfile/v1 -o ../openapi/kptfile.yaml - .PHONY: fix fix: $(MODULES) @for f in $(^D); do (cd $$f; echo "Fixing $$f"; go fix ./...) || exit 1; done @@ -19,12 +14,18 @@ fix: $(MODULES) fmt: $(MODULES) @for f in $(^D); do (cd $$f; echo "Formatting $$f"; go fmt ./...); done +.PHONY: install-golangci-lint +install-golangci-lint: + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2 + .PHONY: lint -lint: $(MODULES) +lint: install-golangci-lint lint-modules + +.PHONY: lint-modules +lint-modules: $(MODULES) @for f in $(^D); do \ (cd $$f; echo "Checking golangci-lint $$f"; \ - (which $(GOPATH)/bin/golangci-lint || go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.4.0); \ - $(GOPATH)/bin/golangci-lint run ./...); \ + $(GOBIN)/golangci-lint run ./...); \ done .PHONY: test diff --git a/go/fn/const.go b/go/fn/const.go index 25e4fce2..1af7248e 100644 --- a/go/fn/const.go +++ b/go/fn/const.go @@ -11,6 +11,7 @@ // 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 const ( @@ -53,6 +54,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" ) @@ -67,5 +69,5 @@ const ( KptFunctionVersion = "v1alpha1" // KptFunctionGroup is the ApiVersion for the KRM resource which defines the configuration of a function execution. // See KRM function specification `ResourceList.FunctionConfig` - KptFunctionApiVersion = KptFunctionGroup + "/" + KptFunctionVersion + KptFunctionAPIVersion = KptFunctionGroup + "/" + KptFunctionVersion ) diff --git a/go/fn/examples/go.mod b/go/fn/examples/go.mod index f632d4b9..6ca7c9ec 100644 --- a/go/fn/examples/go.mod +++ b/go/fn/examples/go.mod @@ -28,6 +28,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect diff --git a/go/fn/examples/go.sum b/go/fn/examples/go.sum index ed8990bf..8febb9bd 100644 --- a/go/fn/examples/go.sum +++ b/go/fn/examples/go.sum @@ -43,6 +43,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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= @@ -55,8 +57,8 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= @@ -108,6 +110,8 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 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/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= diff --git a/go/fn/go.mod b/go/fn/go.mod index c844b6c5..79c4facc 100644 --- a/go/fn/go.mod +++ b/go/fn/go.mod @@ -6,8 +6,9 @@ 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.59 - github.com/stretchr/testify v1.11.1 - k8s.io/apimachinery v0.34.1 + 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. @@ -15,6 +16,8 @@ require ( sigs.k8s.io/kustomize/kyaml v0.20.1 ) +require github.com/pkg/errors v0.9.1 + 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 1142776b..3629bfb4 100644 --- a/go/fn/go.sum +++ b/go/fn/go.sum @@ -31,6 +31,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= @@ -40,8 +42,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -85,8 +87,10 @@ 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= -k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= -k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +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= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= diff --git a/go/fn/internal/map.go b/go/fn/internal/map.go index 36482d33..6283313c 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) @@ -275,11 +284,32 @@ 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 {} + _, err := o.remove(field) + if err != nil { + klog.Warningf("upsert: couldn't remove field %s: %v", field, err) + } + + default: + // field was found and it is NOT a map + _, err := o.remove(field) + if err != nil { + log.Fatalf("KubeObject.UpsertMap(): couldn't remove mistyped field %s: %v", field, err) + } + } } + // 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..e25e76e7 100644 --- a/go/fn/internal/maphelpers.go +++ b/go/fn/internal/maphelpers.go @@ -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...) } 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/test/go.mod b/go/fn/internal/test/go.mod index f11050bd..57c8f506 100644 --- a/go/fn/internal/test/go.mod +++ b/go/fn/internal/test/go.mod @@ -29,6 +29,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect diff --git a/go/fn/internal/test/go.sum b/go/fn/internal/test/go.sum index ed8990bf..3371ac0f 100644 --- a/go/fn/internal/test/go.sum +++ b/go/fn/internal/test/go.sum @@ -43,6 +43,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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= @@ -108,6 +110,8 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 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/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= 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/kptfile.go b/go/fn/kptfile.go new file mode 100644 index 00000000..78d55331 --- /dev/null +++ b/go/fn/kptfile.go @@ -0,0 +1,211 @@ +// 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 + if err := conditionSubObj.SetNestedString(string(condition.Status), "status"); err != nil { + return err + } + if err := conditionSubObj.SetNestedString(condition.Reason, "reason"); err != nil { + return err + } + if err := conditionSubObj.SetNestedString(condition.Message, "message"); err != nil { + return err + } + return kf.SetConditions(conditions) + } + } + ko, err := NewFromTypedObject(condition) + if err != nil { + return err + } + conditions = append(conditions, &ko.SubObject) + return kf.SetConditions(conditions) +} + +// DeleteConditionByType 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) + } + } + if err := info.SetSlice(gateObjs, "readinessGates"); err != nil { + return err + } + 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) + if err := pipeline.SetSlice(mutators, "mutators"); err != nil { + return err + } + return nil +} diff --git a/go/fn/kptfile_test.go b/go/fn/kptfile_test.go new file mode 100644 index 00000000..16e4fda0 --- /dev/null +++ b/go/fn/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 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: ghcr.io/kptdev/krm-functions-catalog/set-labels:latest + 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: ghcr.io/kptdev/krm-functions-catalog/set-labels:latest + 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 2c16404c..ff21bc72 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" @@ -170,7 +168,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 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) 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 +189,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 +203,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 +345,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 +445,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. @@ -482,6 +497,11 @@ func (o *KubeObject) GroupKind() schema.GroupKind { return o.GroupVersionKind().GroupKind() } +// HasSameID 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 @@ -633,13 +653,13 @@ func (o *KubeObject) SetLabel(k, v string) error { return o.SetNestedField(v, "metadata", "labels", k) } -// Label returns one label with key k. +// GetLabel returns one label with key k. func (o *KubeObject) GetLabel(k string) string { v, _, _ := o.obj.GetNestedString("metadata", "labels", k) return v } -// Labels returns all labels. +// GetLabels returns all labels. func (o *KubeObject) GetLabels() map[string]string { v, _, _ := o.obj.GetNestedStringMap("metadata", "labels") return v @@ -672,8 +692,8 @@ func (o *KubeObject) IndexAnnotation() int { return i } -// IdAnnotation return -1 if not found. -func (o *KubeObject) IdAnnotation() int { +// IDAnnotation return -1 if not found. +func (o *KubeObject) IDAnnotation() int { anno := o.GetAnnotation(kioutil.IdAnnotation) if anno == "" { @@ -683,119 +703,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.KptFileGVK().Group, v1.KptFileGVK().Version, v1.KptFileGVK().Kind)) - 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 +735,45 @@ func rnodeToKubeObject(rn *yaml.RNode) *KubeObject { return asKubeObject(mapVariant) } +// 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 +781,25 @@ 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} } +// 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.SetNestedMap(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. @@ -905,9 +865,3 @@ func (s *SliceSubObjects) MarshalJSON() ([]byte, error) { } return yaml.NewRNode(node).MarshalJSON() } - -// DEPRECATED: Please use type-aware functions instead. -// To parse struct object, please use `NestedResource`. -func (o *SubObject) Get(_ interface{}, _ ...string) (bool, error) { - return false, fmt.Errorf("unsupported") -} diff --git a/go/fn/object_io.go b/go/fn/object_io.go new file mode 100644 index 00000000..1ef754a8 --- /dev/null +++ b/go/fn/object_io.go @@ -0,0 +1,137 @@ +// 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" + "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/kio/kioutil" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +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, + }, + } + + 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 4c6ab542..7c84228b 100644 --- a/go/fn/object_test.go +++ b/go/fn/object_test.go @@ -209,7 +209,7 @@ metadata: config.kubernetes.io/local-config: "true" pipeline: mutators: - - image: gcr.io/kpt-fn/set-labels:unstable + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:latest configPath: fn-config.yaml `) item := []byte(` @@ -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") @@ -511,10 +513,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) } } @@ -856,3 +858,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()) + assert.NoError(t, 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: ghcr.io/kptdev/krm-functions-catalog/set-labels:latest + 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..f4f45aa2 --- /dev/null +++ b/go/fn/objects.go @@ -0,0 +1,224 @@ +// 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" + + v1 "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(v1.KptFileGVK())) + 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) + } +} + +// 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/origin.go b/go/fn/origin.go index ee73d008..f9464cc4 100644 --- a/go/fn/origin.go +++ b/go/fn/origin.go @@ -11,6 +11,7 @@ // 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 ( @@ -79,8 +80,8 @@ func (o *KubeObject) effectiveNamespace() string { return UnknownNamespace } -// GetId gets the Group, Kind, Namespace and Name as the ResourceIdentifier. -func (o *KubeObject) GetId() *ResourceIdentifier { +// GetID gets the Group, Kind, Namespace and Name as the ResourceIdentifier. +func (o *KubeObject) GetID() *ResourceIdentifier { group, _ := ParseGroupVersion(o.GetAPIVersion()) return &ResourceIdentifier{ Group: group, @@ -90,13 +91,13 @@ func (o *KubeObject) GetId() *ResourceIdentifier { } } -func parseUpstreamIdentifier(upstreamId string) (*ResourceIdentifier, error) { - upstreamId = strings.TrimSpace(upstreamId) +func parseUpstreamIdentifier(upstreamID string) (*ResourceIdentifier, error) { + upstreamID = strings.TrimSpace(upstreamID) r := regexp.MustCompile(upstreamIdentifierRegexPattern) - match := r.FindStringSubmatch(upstreamId) + match := r.FindStringSubmatch(upstreamID) if match == nil { return nil, &ErrInternalAnnotation{Message: fmt.Sprintf("annotation %v: %v is in bad format. expect %q", - UpstreamIdentifier, upstreamId, upstreamIdentifierFormat)} + UpstreamIdentifier, upstreamID, upstreamIdentifierFormat)} } matchGroups := make(map[string]string) for i, name := range r.SubexpNames() { @@ -112,21 +113,21 @@ func parseUpstreamIdentifier(upstreamId string) (*ResourceIdentifier, error) { }, nil } -// GetOriginId provides the `ResourceIdentifier` to identify the upstream origin of a KRM resource. +// GetOriginID provides the `ResourceIdentifier` to identify the upstream origin of a KRM resource. // This origin is generated and maintained by kpt pkg management and is stored in the `internal.kpt.dev/upstream-identiifer` annotation. // If a resource does not have an upstream origin, we use its current meta resource ID instead. -func (o *KubeObject) GetOriginId() (*ResourceIdentifier, error) { - upstreamId := o.GetAnnotation(UpstreamIdentifier) - if upstreamId != "" { - return parseUpstreamIdentifier(upstreamId) +func (o *KubeObject) GetOriginID() (*ResourceIdentifier, error) { + upstreamID := o.GetAnnotation(UpstreamIdentifier) + if upstreamID != "" { + return parseUpstreamIdentifier(upstreamID) } - return o.GetId(), nil + return o.GetID(), nil } // HasUpstreamOrigin tells whether a resource is sourced from an upstream package resource. func (o *KubeObject) HasUpstreamOrigin() bool { - upstreamId := o.GetAnnotation(UpstreamIdentifier) - return upstreamId != "" + upstreamID := o.GetAnnotation(UpstreamIdentifier) + return upstreamID != "" } // ParseGroupVersion parses a "apiVersion" to get the "group" and "version" values. diff --git a/go/fn/origin_test.go b/go/fn/origin_test.go index caaf6bdd..268d327b 100644 --- a/go/fn/origin_test.go +++ b/go/fn/origin_test.go @@ -36,21 +36,21 @@ metadata: func TestOrigin(t *testing.T) { noGroup, _ := ParseKubeObject(resource) - if id, _ := noGroup.GetOriginId(); id.String() != "|ConfigMap|example|example" { + if id, _ := noGroup.GetOriginID(); id.String() != "|ConfigMap|example|example" { t.Fatalf("GetOriginId() expect %v, got %v", "|ConfigMap|example|example", id) } defaultNamespace, _ := ParseKubeObject(resource) - if defaultNamespace.GetId().String() != "|ConfigMap|default|cm" { - t.Fatalf("GetId() expect %v, got %v", "|ConfigMap|default|cm", defaultNamespace.GetId()) + if defaultNamespace.GetID().String() != "|ConfigMap|default|cm" { + t.Fatalf("GetId() expect %v, got %v", "|ConfigMap|default|cm", defaultNamespace.GetID()) } - sameIdAndOrigin, _ := ParseKubeObject(resourceCustom) - if id, _ := sameIdAndOrigin.GetOriginId(); id.String() != sameIdAndOrigin.GetId().String() { + sameIDAndOrigin, _ := ParseKubeObject(resourceCustom) + if id, _ := sameIDAndOrigin.GetOriginID(); id.String() != sameIDAndOrigin.GetID().String() { t.Fatalf("expect the origin and id the same if upstream-identifier is not given, got OriginID %v, got ID %v", - id, sameIdAndOrigin.GetId()) + id, sameIDAndOrigin.GetID()) } unknownNamespace, _ := ParseKubeObject(resourceCustom) - if unknownNamespace.GetId().Namespace != UnknownNamespace { + if unknownNamespace.GetID().Namespace != UnknownNamespace { t.Fatalf("expect unknown custom resource use namespace %v, got %v", - UnknownNamespace, unknownNamespace.GetId().Namespace) + UnknownNamespace, unknownNamespace.GetID().Namespace) } } diff --git a/go/fn/resourcelist.go b/go/fn/resourcelist.go index a2f4f591..b89f3f81 100644 --- a/go/fn/resourcelist.go +++ b/go/fn/resourcelist.go @@ -9,6 +9,7 @@ import ( "sort" "github.com/kptdev/krm-functions-sdk/go/fn/internal" + pkgerrors "github.com/pkg/errors" "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/kustomize/kyaml/kio" "sigs.k8s.io/kustomize/kyaml/yaml" @@ -84,7 +85,7 @@ func ParseResourceList(in []byte) (*ResourceList, error) { if rlObj.GetKind() != kio.ResourceListKind { return nil, fmt.Errorf("input was of unexpected kind %q; expected ResourceList", rlObj.GetKind()) } - // Parse FunctionConfig. FunctionConfig can be empty, e.g. `kubeval` fn does not require a FunctionConfig. + // Parse FunctionConfig. FunctionConfig can be empty, e.g. `kubeconform` fn does not require a FunctionConfig. fc, found, err := rlObj.obj.GetNestedMap("functionConfig") if err != nil { return nil, fmt.Errorf("failed when tried to get functionConfig: %w", err) @@ -111,15 +112,28 @@ 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, pkgerrors.Wrap(err, "failed when trying to get results") } + + 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 + found = false + } + if err != nil { + return nil, pkgerrors.Wrap(err, "failed when trying to get results") + } + 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) + return nil, pkgerrors.Wrap(err, "failed to decode results") } rl.Results = results } diff --git a/go/fn/io.go b/go/fn/resourcelist_io.go similarity index 93% rename from go/fn/io.go rename to go/fn/resourcelist_io.go index 4d102488..7bf815c3 100644 --- a/go/fn/io.go +++ b/go/fn/resourcelist_io.go @@ -16,15 +16,9 @@ package fn import ( "sigs.k8s.io/kustomize/kyaml/errors" - "sigs.k8s.io/kustomize/kyaml/kio" "sigs.k8s.io/kustomize/kyaml/yaml" ) -// byteReadWriter wraps kio.ByteReadWriter -type byteReadWriter struct { - kio.ByteReadWriter -} - // Read decodes input bytes into a ResourceList func (rw *byteReadWriter) Read() (*ResourceList, error) { nodes, err := rw.ByteReadWriter.Read() diff --git a/go/fn/types.go b/go/fn/types.go new file mode 100644 index 00000000..d6be8529 --- /dev/null +++ b/go/fn/types.go @@ -0,0 +1,23 @@ +// Copyright 2025 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 is the SDK for go krm functions. +package fn + +import "sigs.k8s.io/kustomize/kyaml/kio" + +// byteReadWriter wraps kio.ByteReadWriter +type byteReadWriter struct { + kio.ByteReadWriter +} diff --git a/go/kfn/commands/build_test.go b/go/kfn/commands/build_test.go index 2bf0bd45..68f5d242 100644 --- a/go/kfn/commands/build_test.go +++ b/go/kfn/commands/build_test.go @@ -56,9 +56,9 @@ func TestBuild(t *testing.T) { dockerfileExpected: false, }, "ko as builder, specify repo": { - args: []string{"--repo=gcr.io/test"}, + args: []string{"--repo=ghcr.io/test"}, cmdExpected: []string{ - "KO_DOCKER_REPO=gcr.io/test ko build -B --tags latest", + "KO_DOCKER_REPO=ghcr.io/test ko build -B --tags latest", }, lookPathExpected: map[string]bool{ "ko": true, @@ -66,9 +66,9 @@ func TestBuild(t *testing.T) { dockerfileExpected: false, }, "ko as builder, specify tag": { - args: []string{"--repo=gcr.io/test", "--tag=v1"}, + args: []string{"--repo=ghcr.io/test", "--tag=v1"}, cmdExpected: []string{ - "KO_DOCKER_REPO=gcr.io/test ko build -B --tags v1", + "KO_DOCKER_REPO=ghcr.io/test ko build -B --tags v1", }, lookPathExpected: map[string]bool{ "ko": true, diff --git a/go/kfn/commands/embed/Dockerfile b/go/kfn/commands/embed/Dockerfile index 587680f0..67311a67 100644 --- a/go/kfn/commands/embed/Dockerfile +++ b/go/kfn/commands/embed/Dockerfile @@ -19,6 +19,6 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o /usr/local/bin/function ./ -FROM gcr.io/distroless/static:latest +FROM scratch COPY --from=0 /usr/local/bin/function /usr/local/bin/function ENTRYPOINT ["function"] \ No newline at end of file