diff --git a/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_wait-for.md b/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_wait-for.md index cb0b5add8a..c84cfd44b2 100644 --- a/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_wait-for.md +++ b/docs/2-the-zarf-cli/100-cli-commands/zarf_tools_wait-for.md @@ -27,6 +27,7 @@ zarf tools wait-for { KIND | PROTOCOL } { NAME | SELECTOR | URI } { CONDITION | zarf tools wait-for svc zarf-docker-registry exists -n zarf # wait for service zarf-docker-registry in namespace zarf to exist zarf tools wait-for svc zarf-docker-registry -n zarf # same as above, except exists is the default condition zarf tools wait-for crd addons.k3s.cattle.io # wait for crd addons.k3s.cattle.io to exist + zarf tools wait-for sts test-sts '{.status.availableReplicas}'=23 # wait for statefulset test-sts to have 23 available replicas Wait for network endpoints: zarf tools wait-for http localhost:8080 200 # wait for a 200 response from http://localhost:8080 diff --git a/docs/3-create-a-zarf-package/4-zarf-schema.md b/docs/3-create-a-zarf-package/4-zarf-schema.md index 448c92a270..8cba5ea88b 100644 --- a/docs/3-create-a-zarf-package/4-zarf-schema.md +++ b/docs/3-create-a-zarf-package/4-zarf-schema.md @@ -2297,7 +2297,7 @@ Must be one of:  
-**Description:** The condition to wait for; defaults to exist +**Description:** The condition or jsonpath state to wait for; defaults to exist | | | | -------- | -------- | diff --git a/docs/gen-cli-docs.sh b/docs/gen-cli-docs.sh index 01c5105627..316abf729a 100755 --- a/docs/gen-cli-docs.sh +++ b/docs/gen-cli-docs.sh @@ -15,6 +15,6 @@ for FILE in `find docs/2-the-zarf-cli/100-cli-commands -name "*.md"` do sed -i.bak 's/^##/#/g' ${FILE} sed -i.bak '2s/^/\n/' ${FILE} - sed -i.bak ':a;N;$!ba;s/\n$//' ${FILE} + truncate -s -1 ${FILE} rm ${FILE}.bak done diff --git a/src/cmd/tools/wait.go b/src/cmd/tools/wait.go index 78d6ee0f6c..4453c3bf30 100644 --- a/src/cmd/tools/wait.go +++ b/src/cmd/tools/wait.go @@ -5,17 +5,11 @@ package tools import ( - "fmt" - "net" - "net/http" - "strconv" - "strings" "time" "github.com/defenseunicorns/zarf/src/config/lang" "github.com/defenseunicorns/zarf/src/pkg/message" "github.com/defenseunicorns/zarf/src/pkg/utils" - "github.com/defenseunicorns/zarf/src/pkg/utils/exec" "github.com/spf13/cobra" // Import to initialize client auth plugins. @@ -50,160 +44,11 @@ var waitForCmd = &cobra.Command{ condition = args[2] } - // Handle network endpoints. - switch kind { - case "http", "https", "tcp": - waitForNetworkEndpoint(kind, identifier, condition, timeout) - return - } - - // Get the Zarf executable path. - zarfBinPath, err := utils.GetFinalExecutablePath() - if err != nil { - message.Fatal(err, lang.CmdToolsWaitForErrZarfPath) - } - - // If the identifier contains an equals sign, convert to a label selector. - identifierMsg := fmt.Sprintf("/%s", identifier) - if strings.ContainsRune(identifier, '=') { - identifierMsg = fmt.Sprintf(" with label `%s`", identifier) - identifier = fmt.Sprintf("-l %s", identifier) - } - - // Set the timeout for the wait-for command. - expired := time.After(timeout) - - // Set the custom message for optional namespace. - namespaceMsg := "" - if waitNamespace != "" { - namespaceMsg = fmt.Sprintf(" in namespace %s", waitNamespace) - } - - // Setup the spinner messages. - conditionMsg := fmt.Sprintf("Waiting for %s%s%s to be %s.", kind, identifierMsg, namespaceMsg, condition) - existMsg := fmt.Sprintf("Waiting for %s%s%s to exist.", kind, identifierMsg, namespaceMsg) - spinner := message.NewProgressSpinner(existMsg) - defer spinner.Stop() - - for { - // Delay the check for 1 second - time.Sleep(time.Second) - - select { - case <-expired: - message.Fatal(nil, lang.CmdToolsWaitForErrTimeout) - - default: - spinner.Updatef(existMsg) - // Check if the resource exists. - args := []string{"tools", "kubectl", "get", "-n", waitNamespace, kind, identifier} - if stdout, stderr, err := exec.Cmd(zarfBinPath, args...); err != nil { - message.Debug(stdout, stderr, err) - continue - } - - // If only checking for existence, exit here. - switch condition { - case "", "exist", "exists": - spinner.Success() - return - } - - spinner.Updatef(conditionMsg) - // Wait for the resource to meet the given condition. - args = []string{"tools", "kubectl", "wait", "-n", waitNamespace, - kind, identifier, "--for", "condition=" + condition, - "--timeout=" + waitTimeout} - - // If there is an error, log it and try again. - if stdout, stderr, err := exec.Cmd(zarfBinPath, args...); err != nil { - message.Debug(stdout, stderr, err) - continue - } - - // And just like that, success! - spinner.Successf(conditionMsg) - return - } - } + // Execute the wait command. + utils.ExecuteWait(waitTimeout, waitNamespace, condition, kind, identifier, timeout) }, } -func waitForNetworkEndpoint(resource, name, condition string, timeout time.Duration) { - // Set the timeout for the wait-for command. - expired := time.After(timeout) - - // Setup the spinner messages. - condition = strings.ToLower(condition) - if condition == "" { - condition = "success" - } - spinner := message.NewProgressSpinner("Waiting for network endpoint %s://%s to respond %s.", resource, name, condition) - defer spinner.Stop() - - delay := 100 * time.Millisecond - - for { - // Delay the check for 100ms the first time and then 1 second after that. - time.Sleep(delay) - delay = time.Second - - select { - case <-expired: - message.Fatal(nil, lang.CmdToolsWaitForErrTimeout) - - default: - switch resource { - - case "http", "https": - // Handle HTTP and HTTPS endpoints. - url := fmt.Sprintf("%s://%s", resource, name) - - // Default to checking for a 2xx response. - if condition == "success" { - // Try to get the URL and check the status code. - resp, err := http.Get(url) - - // If the status code is not in the 2xx range, try again. - if err != nil || resp.StatusCode < 200 || resp.StatusCode > 299 { - message.Debug(err) - continue - } - - // Success, break out of the swtich statement. - break - } - - // Convert the condition to an int and check if it's a valid HTTP status code. - code, err := strconv.Atoi(condition) - if err != nil || http.StatusText(code) == "" { - message.Fatalf(err, lang.CmdToolsWaitForErrConditionString, condition) - } - - // Try to get the URL and check the status code. - resp, err := http.Get(url) - if err != nil || resp.StatusCode != code { - message.Debug(err) - continue - } - - default: - // Fallback to any generic protocol using net.Dial - conn, err := net.Dial(resource, name) - if err != nil { - message.Debug(err) - continue - } - defer conn.Close() - } - - // Yay, we made it! - spinner.Success() - return - } - } -} - func init() { toolsCmd.AddCommand(waitForCmd) waitForCmd.Flags().StringVar(&waitTimeout, "timeout", "5m", lang.CmdToolsWaitForFlagTimeout) diff --git a/src/config/lang/english.go b/src/config/lang/english.go index 318839ca74..ca50cabf18 100644 --- a/src/config/lang/english.go +++ b/src/config/lang/english.go @@ -411,6 +411,7 @@ const ( zarf tools wait-for svc zarf-docker-registry exists -n zarf # wait for service zarf-docker-registry in namespace zarf to exist zarf tools wait-for svc zarf-docker-registry -n zarf # same as above, except exists is the default condition zarf tools wait-for crd addons.k3s.cattle.io # wait for crd addons.k3s.cattle.io to exist + zarf tools wait-for sts test-sts '{.status.availableReplicas}'=23 # wait for statefulset test-sts to have 23 available replicas Wait for network endpoints: zarf tools wait-for http localhost:8080 200 # wait for a 200 response from http://localhost:8080 diff --git a/src/pkg/utils/wait.go b/src/pkg/utils/wait.go new file mode 100644 index 0000000000..976b93dce4 --- /dev/null +++ b/src/pkg/utils/wait.go @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package utils provides generic helper functions. +package utils + +import ( + "fmt" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/defenseunicorns/zarf/src/pkg/utils/exec" + + "github.com/defenseunicorns/zarf/src/config/lang" + "github.com/defenseunicorns/zarf/src/pkg/message" +) + +// isJSONPathWaitType checks if the condition is a JSONPath or condition. +func isJSONPathWaitType(condition string) bool { + if len(condition) == 0 || condition[0] != '{' || !strings.Contains(condition, "=") || !strings.Contains(condition, "}") { + return false + } + + return true +} + +// ExecuteWait executes the wait-for command. +func ExecuteWait(waitTimeout, waitNamespace, condition, kind, identifier string, timeout time.Duration) { + // Handle network endpoints. + switch kind { + case "http", "https", "tcp": + waitForNetworkEndpoint(kind, identifier, condition, timeout) + return + } + + // Type of wait, condition or JSONPath + var waitType string + + // Check if waitType is JSONPath or condition + if isJSONPathWaitType(condition) { + waitType = "jsonpath=" + } else { + waitType = "condition=" + } + + // Get the Zarf executable path. + zarfBinPath, err := GetFinalExecutablePath() + if err != nil { + message.Fatal(err, lang.CmdToolsWaitForErrZarfPath) + } + + // If the identifier contains an equals sign, convert to a label selector. + identifierMsg := fmt.Sprintf("/%s", identifier) + if strings.ContainsRune(identifier, '=') { + identifierMsg = fmt.Sprintf(" with label `%s`", identifier) + identifier = fmt.Sprintf("-l %s", identifier) + } + + // Set the timeout for the wait-for command. + expired := time.After(timeout) + + // Set the custom message for optional namespace. + namespaceMsg := "" + if waitNamespace != "" { + namespaceMsg = fmt.Sprintf(" in namespace %s", waitNamespace) + } + + // Setup the spinner messages. + conditionMsg := fmt.Sprintf("Waiting for %s%s%s to be %s.", kind, identifierMsg, namespaceMsg, condition) + existMsg := fmt.Sprintf("Waiting for %s%s%s to exist.", kind, identifierMsg, namespaceMsg) + spinner := message.NewProgressSpinner(existMsg) + + defer spinner.Stop() + + for { + // Delay the check for 1 second + time.Sleep(time.Second) + + select { + case <-expired: + message.Fatal(nil, lang.CmdToolsWaitForErrTimeout) + + default: + spinner.Updatef(existMsg) + // Check if the resource exists. + args := []string{"tools", "kubectl", "get", "-n", waitNamespace, kind, identifier} + if stdout, stderr, err := exec.Cmd(zarfBinPath, args...); err != nil { + message.Debug(stdout, stderr, err) + continue + } + + // If only checking for existence, exit here. + switch condition { + case "", "exist", "exists": + spinner.Success() + return + } + + spinner.Updatef(conditionMsg) + // Wait for the resource to meet the given condition. + args = []string{"tools", "kubectl", "wait", "-n", waitNamespace, + kind, identifier, "--for", waitType + condition, + "--timeout=" + waitTimeout} + + // If there is an error, log it and try again. + if stdout, stderr, err := exec.Cmd(zarfBinPath, args...); err != nil { + message.Debug(stdout, stderr, err) + continue + } + + // And just like that, success! + spinner.Successf(conditionMsg) + return + } + } +} + +// waitForNetworkEndpoint waits for a network endpoint to respond. +func waitForNetworkEndpoint(resource, name, condition string, timeout time.Duration) { + // Set the timeout for the wait-for command. + expired := time.After(timeout) + + // Setup the spinner messages. + condition = strings.ToLower(condition) + if condition == "" { + condition = "success" + } + spinner := message.NewProgressSpinner("Waiting for network endpoint %s://%s to respond %s.", resource, name, condition) + defer spinner.Stop() + + delay := 100 * time.Millisecond + + for { + // Delay the check for 100ms the first time and then 1 second after that. + time.Sleep(delay) + delay = time.Second + + select { + case <-expired: + message.Fatal(nil, lang.CmdToolsWaitForErrTimeout) + + default: + switch resource { + + case "http", "https": + // Handle HTTP and HTTPS endpoints. + url := fmt.Sprintf("%s://%s", resource, name) + + // Default to checking for a 2xx response. + if condition == "success" { + // Try to get the URL and check the status code. + resp, err := http.Get(url) + + // If the status code is not in the 2xx range, try again. + if err != nil || resp.StatusCode < 200 || resp.StatusCode > 299 { + message.Debug(err) + continue + } + + // Success, break out of the swtich statement. + break + } + + // Convert the condition to an int and check if it's a valid HTTP status code. + code, err := strconv.Atoi(condition) + if err != nil || http.StatusText(code) == "" { + message.Fatal(err, lang.CmdToolsWaitForErrConditionString) + } + + // Try to get the URL and check the status code. + resp, err := http.Get(url) + if err != nil || resp.StatusCode != code { + message.Debug(err) + continue + } + + default: + // Fallback to any generic protocol using net.Dial + conn, err := net.Dial(resource, name) + if err != nil { + message.Debug(err) + continue + } + defer conn.Close() + } + + // Yay, we made it! + spinner.Success() + return + } + } +} diff --git a/src/pkg/utils/wait_test.go b/src/pkg/utils/wait_test.go new file mode 100644 index 0000000000..1c66400ae1 --- /dev/null +++ b/src/pkg/utils/wait_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +// Package utils provides generic helper functions. +package utils + +import ( + "testing" + + "github.com/defenseunicorns/zarf/src/pkg/message" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type TestIsJSONPathWaitTypeSuite struct { + suite.Suite + *require.Assertions + waitTypes testWaitTypes +} + +type testWaitTypes struct { + jsonPathType []string + conditionType []string +} + +func (suite *TestIsJSONPathWaitTypeSuite) SetupSuite() { + suite.Assertions = require.New(suite.T()) + + suite.waitTypes.jsonPathType = []string{ + "{.status.availableReplicas}=1", + "{.status.containerStatuses[0].ready}=true", + "{.spec.containers[0].ports[0].containerPort}=80", + "{.spec.nodeName}=knode0", + } + suite.waitTypes.conditionType = []string{ + "Ready", + "delete", + "", + } +} + +func (suite *TestIsJSONPathWaitTypeSuite) Test_0_IsJSONPathWaitType() { + for _, waitType := range suite.waitTypes.conditionType { + suite.False(isJSONPathWaitType(waitType), "Expected %s not to be a JSONPath wait type", waitType) + } + for _, waitType := range suite.waitTypes.jsonPathType { + suite.True(isJSONPathWaitType(waitType), "Expected %s to be a JSONPath wait type", waitType) + } +} + +func TestIsJSONPathWaitType(t *testing.T) { + message.SetLogLevel(message.DebugLevel) + suite.Run(t, new(TestIsJSONPathWaitTypeSuite)) +} diff --git a/src/types/component.go b/src/types/component.go index 38dde99e83..77e4be0b8c 100644 --- a/src/types/component.go +++ b/src/types/component.go @@ -185,7 +185,7 @@ type ZarfComponentActionWaitCluster struct { Kind string `json:"kind" jsonschema:"description=The kind of resource to wait for,example=Pod,example=Deployment)"` Identifier string `json:"name" jsonschema:"description=The name of the resource or selector to wait for,example=podinfo,example=app=podinfo"` Namespace string `json:"namespace,omitempty" jsonschema:"description=The namespace of the resource to wait for"` - Condition string `json:"condition,omitempty" jsonschema:"description=The condition to wait for; defaults to exist, a special condition that will wait for the resource to exist,example=Ready,example=Available"` + Condition string `json:"condition,omitempty" jsonschema:"description=The condition or jsonpath state to wait for; defaults to exist, a special condition that will wait for the resource to exist,example=Ready,example=Available,'{.status.availableReplicas}'=23"` } // ZarfComponentActionWaitNetwork specifies a condition to wait for before continuing diff --git a/src/ui/lib/api-types.ts b/src/ui/lib/api-types.ts index 2d2105da3c..ea03bfaf1d 100644 --- a/src/ui/lib/api-types.ts +++ b/src/ui/lib/api-types.ts @@ -506,7 +506,7 @@ export interface ZarfComponentActionWait { */ export interface ZarfComponentActionWaitCluster { /** - * The condition to wait for; defaults to exist + * The condition or jsonpath state to wait for; defaults to exist */ condition?: string; /** diff --git a/zarf.schema.json b/zarf.schema.json index 171fca20e1..768e4d7004 100644 --- a/zarf.schema.json +++ b/zarf.schema.json @@ -557,7 +557,7 @@ }, "condition": { "type": "string", - "description": "The condition to wait for; defaults to exist", + "description": "The condition or jsonpath state to wait for; defaults to exist", "examples": [ "Ready", "Available"