Skip to content

Commit

Permalink
Improve conditionFuncFor expression parsing for wait --for jsonpath
Browse files Browse the repository at this point in the history
Make it possible to parse jsonpath filter expressions: Split
jsonpath expressions on single '=' only and leave '==' as part of the
string.

Reported-at: https://github.com/kubernetes/kubernetes/issues/119206
Signed-off-by: Andreas Karis <ak.karis@gmail.com>
  • Loading branch information
andreaskaris committed Jul 23, 2023
1 parent 7581ae8 commit 4188998
Show file tree
Hide file tree
Showing 3 changed files with 126 additions and 22 deletions.
34 changes: 30 additions & 4 deletions staging/src/k8s.io/kubectl/pkg/cmd/wait/wait.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ var (
# Wait for the pod "busybox1" to contain the status phase to be "Running"
kubectl wait --for=jsonpath='{.status.phase}'=Running pod/busybox1
# Wait for pod "busybox1" to be Ready
kubectl wait --for='jsonpath={.status.conditions[?(@.type=="Ready")].status}=True' pod/busybox1
# Wait for the service "loadbalancer" to have ingress.
kubectl wait --for=jsonpath='{.status.loadBalancer.ingress}' service/loadbalancer
Expand Down Expand Up @@ -209,8 +212,8 @@ func conditionFuncFor(condition string, errOut io.Writer) (ConditionFunc, error)
}.IsConditionMet, nil
}
if strings.HasPrefix(condition, "jsonpath=") {
splitStr := strings.Split(condition, "=")
jsonPathExp, jsonPathValue, err := processJSONPathInput(splitStr[1:])
jsonPathInput := strings.TrimPrefix(condition, "jsonpath=")
jsonPathExp, jsonPathValue, err := processJSONPathInput(jsonPathInput)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -241,8 +244,10 @@ func newJSONPathParser(jsonPathExpression string) (*jsonpath.JSONPath, error) {
return j, nil
}

// processJSONPathInput will parses the user's JSONPath input containing JSON expression and, optionally, JSON value for matching condition and process it
func processJSONPathInput(jsonPathInput []string) (string, string, error) {
// processJSONPathInput will parse and process the provided JSONPath input containing a JSON expression and optionally
// a value for the matching condition.
func processJSONPathInput(input string) (string, string, error) {
jsonPathInput := splitJSONPathInput(input)
if numOfArgs := len(jsonPathInput); numOfArgs < 1 || numOfArgs > 2 {
return "", "", fmt.Errorf("jsonpath wait format must be --for=jsonpath='{.status.readyReplicas}'=3 or --for=jsonpath='{.status.readyReplicas}'")
}
Expand All @@ -260,6 +265,27 @@ func processJSONPathInput(jsonPathInput []string) (string, string, error) {
return relaxedJSONPathExp, jsonPathValue, nil
}

// splitJSONPathInput splits the provided input string on single '='. Double '==' will not cause the string to be
// split. E.g., "a.b.c====d.e.f===g.h.i===" will split to ["a.b.c====d.e.f==","g.h.i==",""].
func splitJSONPathInput(input string) []string {
var output []string
var element strings.Builder
for i := 0; i < len(input); i++ {
if input[i] == '=' {
if i < len(input)-1 && input[i+1] == '=' {
element.WriteString("==")
i++
continue
}
output = append(output, element.String())
element.Reset()
continue
}
element.WriteByte(input[i])
}
return append(output, element.String())
}

// ResourceLocation holds the location of a resource
type ResourceLocation struct {
GroupResource schema.GroupResource
Expand Down
105 changes: 89 additions & 16 deletions staging/src/k8s.io/kubectl/pkg/cmd/wait/wait_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1534,39 +1534,112 @@ func TestWaitForJSONPathCondition(t *testing.T) {
}
}

// TestWaitForJSONPathBadConditionParsing will test errors in parsing JSONPath bad condition expressions
// except for parsing JSONPath expression itself (i.e. call to cmdget.RelaxedJSONPathExpression())
func TestWaitForJSONPathBadConditionParsing(t *testing.T) {
// TestConditionFuncFor tests that the condition string can be properly parsed into a ConditionFunc.
func TestConditionFuncFor(t *testing.T) {
tests := []struct {
name string
condition string
expectedResult JSONPathWait
expectedErr string
name string
condition string
expectedErr string
}{
{
name: "missing JSONPath expression",
name: "jsonpath missing JSONPath expression",
condition: "jsonpath=",
expectedErr: "jsonpath expression cannot be empty",
},
{
name: "value in JSONPath expression has equal sign",
name: "jsonpath check for condition without value",
condition: "jsonpath={.metadata.name}",
expectedErr: None,
},
{
name: "jsonpath check for condition without value relaxed parsing",
condition: "jsonpath=abc",
expectedErr: None,
},
{
name: "jsonpath check for expression and value",
condition: "jsonpath={.metadata.name}=foo-b6699dcfb-rnv7t",
expectedErr: None,
},
{
name: "jsonpath check for expression and value relaxed parsing",
condition: "jsonpath=.metadata.name=foo-b6699dcfb-rnv7t",
expectedErr: None,
},
{
name: "jsonpath selecting based on condition",
condition: `jsonpath={.status.containerStatuses[?(@.name=="foo")].ready}=True`,
expectedErr: None,
},
{
name: "jsonpath selecting based on condition relaxed parsing",
condition: "jsonpath=status.conditions[?(@.type==\"Available\")].status=True",
expectedErr: None,
},
{
name: "jsonpath selecting based on condition without value",
condition: `jsonpath={.status.containerStatuses[?(@.name=="foo")].ready}`,
expectedErr: None,
},
{
name: "jsonpath selecting based on condition without value relaxed parsing",
condition: `jsonpath=.status.containerStatuses[?(@.name=="foo")].ready`,
expectedErr: None,
},
{
name: "jsonpath invalid expression with repeated '='",
condition: "jsonpath={.metadata.name}='test=wrong'",
expectedErr: "jsonpath wait format must be --for=jsonpath='{.status.readyReplicas}'=3 or --for=jsonpath='{.status.readyReplicas}'",
},
{
name: "undefined value",
name: "jsonpath undefined value after '='",
condition: "jsonpath={.metadata.name}=",
expectedErr: "jsonpath wait has to have a value after equal sign, like --for=jsonpath='{.status.readyReplicas}'=3",
expectedErr: "jsonpath wait has to have a value after equal sign",
},
{
name: "jsonpath complex expressions not supported",
condition: "jsonpath={.status.conditions[?(@.type==\"Failed\"||@.type==\"Complete\")].status}=True",
expectedErr: "unrecognized character in action: U+007C '|'",
},
{
name: "jsonpath invalid expression",
condition: "jsonpath={=True",
expectedErr: "unexpected path string, expected a 'name1.name2' or '.name1.name2' or '{name1.name2}' or " +
"'{.name1.name2}'",
},
{
name: "condition delete",
condition: "delete",
expectedErr: None,
},
{
name: "condition true",
condition: "condition=hello",
expectedErr: None,
},
{
name: "condition with value",
condition: "condition=hello=world",
expectedErr: None,
},
{
name: "unrecognized condition",
condition: "cond=invalid",
expectedErr: "unrecognized condition: \"cond=invalid\"",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := conditionFuncFor(test.condition, io.Discard)
if err == nil && test.expectedErr != "" {
t.Fatalf("expected %q, got empty", test.expectedErr)
}
if !strings.Contains(err.Error(), test.expectedErr) {
t.Fatalf("expected %q, got %q", test.expectedErr, err.Error())
switch {
case err == nil && test.expectedErr != None:
t.Fatalf("expected error %q, got nil", test.expectedErr)
case err != nil && test.expectedErr == None:
t.Fatalf("expected no error, got %q", err)
case err != nil && test.expectedErr != None:
if !strings.Contains(err.Error(), test.expectedErr) {
t.Fatalf("expected error %q, got %q", test.expectedErr, err.Error())
}
}
})
}
Expand Down
9 changes: 7 additions & 2 deletions test/cmd/wait.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,16 @@ EOF
# wait with jsonpath without value to succeed
set +o errexit
# Command: Wait with jsonpath without value
output_message=$(kubectl wait --for=jsonpath='{.status.replicas}' deploy/test-3 2>&1)
output_message_0=$(kubectl wait --for=jsonpath='{.status.replicas}' deploy/test-3 2>&1)
# Command: Wait with relaxed jsonpath and filter expression
output_message_1=$(kubectl wait \
--for='jsonpath=spec.template.spec.containers[?(@.name=="busybox")].image=busybox' \
deploy/test-3)
set -o errexit

# Post-Condition: Wait succeed
kube::test::if_has_string "${output_message}" 'deployment.apps/test-3 condition met'
kube::test::if_has_string "${output_message_0}" 'deployment.apps/test-3 condition met'
kube::test::if_has_string "${output_message_1}" 'deployment.apps/test-3 condition met'

# Clean deployment
kubectl delete deployment test-3
Expand Down

0 comments on commit 4188998

Please sign in to comment.