Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions e2e/testdata/live-apply/invalid-annotation-type/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright 2026 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.

parallel: true

kptArgs:
- "live"
- "apply"

# Expected exit code is non-zero (failure)
exitCode: 1

# Error should be in stderr with clear message about which field failed
stdErr: |
error: annotation "example.com/enabled" must be a string, got boolean
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
apiVersion: kpt.dev/v1
kind: Kptfile
metadata:
name: invalid-annotation-type
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: bad-config
annotations:
example.com/enabled: true
data:
key: value
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apiVersion: kpt.dev/v1alpha1
kind: ResourceGroup
metadata:
name: inventory-obj
namespace: invalid-annotation-type
labels:
cli-utils.sigs.k8s.io/inventory-id: test-invalid-annotation
26 changes: 26 additions & 0 deletions e2e/testdata/live-apply/invalid-label-type/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright 2026 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.

parallel: true

kptArgs:
- "live"
- "apply"

# Expected exit code is non-zero (failure)
exitCode: 1

# Error should be in stderr with clear message about which field failed
stdErr: |
error: label "app.kubernetes.io/enabled" must be a string, got boolean
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
apiVersion: kpt.dev/v1
kind: Kptfile
metadata:
name: invalid-label-type
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: bad-label-config
labels:
app.kubernetes.io/enabled: true
data:
key: value
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apiVersion: kpt.dev/v1alpha1
kind: ResourceGroup
metadata:
name: inventory-obj
namespace: invalid-label-type
labels:
cli-utils.sigs.k8s.io/inventory-id: test-invalid-label
18 changes: 18 additions & 0 deletions pkg/live/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ kind: ConfigMap
metadata:
name: cm
data: {}
`
configMapWithBadAnnotation = `
apiVersion: v1
kind: ConfigMap
metadata:
name: cm
annotations:
example.com/bad-anno: true
data: {}
`
configMapWithBadLabel = `
apiVersion: v1
kind: ConfigMap
metadata:
name: cm
labels:
app.kubernetes.io/enabled: true
data: {}
`
crd = `
apiVersion: apiextensions.k8s.io/v1
Expand Down
107 changes: 107 additions & 0 deletions pkg/live/rgpath.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package live

import (
"encoding/json"
"fmt"

"github.com/kptdev/kpt/internal/util/pathutil"
rgfilev1alpha1 "github.com/kptdev/kpt/pkg/api/resourcegroup/v1alpha1"
Expand Down Expand Up @@ -93,6 +94,9 @@ func removeAnnotations(n *yaml.RNode, annotations ...kioutil.AnnotationKey) erro
//
//nolint:interfacer
func kyamlNodeToUnstructured(n *yaml.RNode) (*unstructured.Unstructured, error) {
if err := validateMetadataStringMaps(n); err != nil {
return nil, err
}
b, err := n.MarshalJSON()
if err != nil {
return nil, err
Expand All @@ -109,6 +113,109 @@ func kyamlNodeToUnstructured(n *yaml.RNode) (*unstructured.Unstructured, error)
}, nil
}

// validateMetadataStringMaps inspects the raw YAML nodes for metadata.annotations
// and metadata.labels to ensure all values are strings. This prevents silent data
// loss that occurs when non-string values (booleans, integers, floats) are
// unmarshaled into map[string]string fields.
//
// Parameters:
// - n: The RNode representing a Kubernetes resource
//
// Returns:
// - error: A descriptive error if any annotation or label value is not a string,
// nil otherwise
func validateMetadataStringMaps(n *yaml.RNode) error {
metadata := n.Field("metadata")
if metadata == nil || metadata.Value == nil {
return nil
}

// Validate annotations
if err := validateStringMap(metadata.Value, "annotations", "annotation"); err != nil {
return err
}

// Validate labels
if err := validateStringMap(metadata.Value, "labels", "label"); err != nil {
return err
}

return nil
}

// validateStringMap checks that all values in a metadata field (annotations or labels)
// are strings. It inspects the YAML tag of each value to detect non-string types
// that would be silently dropped during unmarshaling.
//
// Parameters:
// - metadata: The RNode for the metadata field
// - fieldName: The name of the field to validate ("annotations" or "labels")
// - fieldType: Human-readable name for error messages ("annotation" or "label")
//
// Returns:
// - error: A descriptive error if any value is not a string, nil otherwise
func validateStringMap(metadata *yaml.RNode, fieldName, fieldType string) error {
field := metadata.Field(fieldName)
if field == nil || field.Value == nil {
return nil
}

mapNode := field.Value.YNode()
if mapNode == nil {
return nil
}

// Handle explicit null (e.g., annotations: null)
if mapNode.Tag == yaml.NodeTagNull {
return nil
}

if mapNode.Kind != yaml.MappingNode {
return fmt.Errorf("metadata.%s must be a string map", fieldName)
}

for i := 0; i < len(mapNode.Content); i += 2 {
keyNode := mapNode.Content[i]
valueNode := mapNode.Content[i+1]
if valueNode.Kind != yaml.ScalarNode || valueNode.Tag != yaml.NodeTagString {
return fmt.Errorf("%s %q must be a string, got %s", fieldType, keyNode.Value, yamlTagToType(valueNode))
}
}

return nil
}

// yamlTagToType converts a YAML node's tag to a human-readable type name
// for use in error messages.
//
// Parameters:
// - node: The YAML node to inspect
//
// Returns:
// - string: A human-readable type name (e.g., "boolean", "integer", "number")
func yamlTagToType(node *yaml.Node) string {
if node.Kind != yaml.ScalarNode {
return "non-scalar"
}
switch node.Tag {
case yaml.NodeTagBool:
return "boolean"
case yaml.NodeTagInt:
return "integer"
case yaml.NodeTagFloat:
return "number"
case yaml.NodeTagNull:
return "null"
case yaml.NodeTagString:
return "string"
default:
if node.Tag == yaml.NodeTagEmpty {
return "unknown"
}
return node.Tag
}
}

const NoLocalConfigAnnoVal = "false"

// filterLocalConfig returns a new slice of Unstructured where all resources
Expand Down
14 changes: 14 additions & 0 deletions pkg/live/rgpath_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,20 @@ func TestPathManifestReader_Read(t *testing.T) {
},
},
},
"Bad annotation type is rejected": {
manifests: map[string]string{
"bad-anno.yaml": configMapWithBadAnnotation,
},
namespace: "test-namespace",
expectedErrMsg: "annotation \"example.com/bad-anno\" must be a string, got boolean",
},
"Bad label type is rejected": {
manifests: map[string]string{
"bad-label.yaml": configMapWithBadLabel,
},
namespace: "test-namespace",
expectedErrMsg: "label \"app.kubernetes.io/enabled\" must be a string, got boolean",
},
}

for tn, tc := range testCases {
Expand Down
14 changes: 14 additions & 0 deletions pkg/live/rgstream_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,20 @@ func TestResourceStreamManifestReader_Read(t *testing.T) {
namespace: "test-namespace",
expectedErrMsg: "unknown resource types: custom.io/v1/Custom",
},
"Bad annotation type is rejected": {
manifests: map[string]string{
"bad-anno.yaml": configMapWithBadAnnotation,
},
namespace: "test-namespace",
expectedErrMsg: "annotation \"example.com/bad-anno\" must be a string, got boolean",
},
"Bad label type is rejected": {
manifests: map[string]string{
"bad-label.yaml": configMapWithBadLabel,
},
namespace: "test-namespace",
expectedErrMsg: "label \"app.kubernetes.io/enabled\" must be a string, got boolean",
},
}

for tn, tc := range testCases {
Expand Down
Loading
Loading