diff --git a/.github/workflows/self_hosted_e2e.yaml b/.github/workflows/self_hosted_e2e.yaml index 6cb796019..1aca5bf41 100644 --- a/.github/workflows/self_hosted_e2e.yaml +++ b/.github/workflows/self_hosted_e2e.yaml @@ -131,7 +131,7 @@ jobs: shell: bash - name: Set the test timeout - MacOS if: matrix.os == 'macos-14-large' - run: echo "E2E_SH_TEST_TIMEOUT=30m" >> $GITHUB_ENV + run: echo "E2E_SH_TEST_TIMEOUT=40m" >> $GITHUB_ENV - name: Run E2E tests with GHCR # runs every 6hrs if: github.event.schedule == '0 */6 * * *' diff --git a/Makefile b/Makefile index 8116f3cb1..c6b853a23 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ TEST_OUTPUT_FILE ?= test_output.json # Set the default timeout for tests to 10 minutes ifndef E2E_SH_TEST_TIMEOUT - override E2E_SH_TEST_TIMEOUT := 30m + override E2E_SH_TEST_TIMEOUT := 40m endif # Use the variable H to add a header (equivalent to =>) to informational output diff --git a/cmd/dapr.go b/cmd/dapr.go index d539ef047..5f943a553 100644 --- a/cmd/dapr.go +++ b/cmd/dapr.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/viper" "github.com/dapr/cli/cmd/scheduler" + "github.com/dapr/cli/cmd/workflow" "github.com/dapr/cli/pkg/api" "github.com/dapr/cli/pkg/print" "github.com/dapr/cli/pkg/standalone" @@ -111,4 +112,5 @@ func init() { RootCmd.PersistentFlags().BoolVarP(&logAsJSON, "log-as-json", "", false, "Log output in JSON format") RootCmd.AddCommand(scheduler.SchedulerCmd) + RootCmd.AddCommand(workflow.WorkflowCmd) } diff --git a/cmd/workflow/history.go b/cmd/workflow/history.go new file mode 100644 index 000000000..afa153eca --- /dev/null +++ b/cmd/workflow/history.go @@ -0,0 +1,85 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "os" + + "github.com/gocarina/gocsv" + "github.com/spf13/cobra" + + "github.com/dapr/cli/pkg/workflow" + "github.com/dapr/cli/utils" + "github.com/dapr/kit/signals" +) + +var ( + historyOutputFormat *string +) + +var HistoryCmd = &cobra.Command{ + Use: "history", + Short: "Get the history of a workflow instance.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := signals.Context() + + appID, err := getWorkflowAppID(cmd) + if err != nil { + return err + } + + opts := workflow.HistoryOptions{ + KubernetesMode: flagKubernetesMode, + Namespace: flagDaprNamespace, + AppID: appID, + InstanceID: args[0], + } + + var list any + if *historyOutputFormat == outputFormatShort { + list, err = workflow.HistoryShort(ctx, opts) + } else { + list, err = workflow.HistoryWide(ctx, opts) + } + if err != nil { + return err + } + + switch *historyOutputFormat { + case outputFormatYAML: + err = utils.PrintDetail(os.Stdout, "yaml", list) + case outputFormatJSON: + err = utils.PrintDetail(os.Stdout, "json", list) + default: + var table string + table, err = gocsv.MarshalString(list) + if err != nil { + break + } + + utils.PrintTable(table) + } + if err != nil { + return err + } + + return nil + }, +} + +func init() { + historyOutputFormat = outputFunc(HistoryCmd) + WorkflowCmd.AddCommand(HistoryCmd) +} diff --git a/cmd/workflow/list.go b/cmd/workflow/list.go new file mode 100644 index 000000000..d3928d572 --- /dev/null +++ b/cmd/workflow/list.go @@ -0,0 +1,113 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "os" + + "github.com/gocarina/gocsv" + "github.com/spf13/cobra" + + "github.com/dapr/cli/pkg/print" + "github.com/dapr/cli/pkg/workflow" + "github.com/dapr/cli/utils" + "github.com/dapr/kit/signals" +) + +var ( + listFilter *workflow.Filter + listOutputFormat *string + + listConn *connFlag +) + +var ListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List workflows for the given app ID.", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := signals.Context() + + appID, err := getWorkflowAppID(cmd) + if err != nil { + return err + } + + opts := workflow.ListOptions{ + KubernetesMode: flagKubernetesMode, + Namespace: flagDaprNamespace, + AppID: appID, + ConnectionString: listConn.connectionString, + TableName: listConn.tableName, + Filter: *listFilter, + } + + var list any + var empty bool + + switch *listOutputFormat { + case outputFormatShort: + var ll []*workflow.ListOutputShort + ll, err = workflow.ListShort(ctx, opts) + if err != nil { + return err + } + empty = len(ll) == 0 + list = ll + + default: + var ll []*workflow.ListOutputWide + ll, err = workflow.ListWide(ctx, opts) + if err != nil { + return err + } + empty = len(ll) == 0 + list = ll + } + + if empty { + print.FailureStatusEvent(os.Stderr, "No workflow found in namespace %q for app ID %q", flagDaprNamespace, appID) + return nil + } + + switch *listOutputFormat { + case outputFormatYAML: + err = utils.PrintDetail(os.Stdout, "yaml", list) + case outputFormatJSON: + err = utils.PrintDetail(os.Stdout, "json", list) + default: + var table string + table, err = gocsv.MarshalString(list) + if err != nil { + break + } + + utils.PrintTable(table) + } + + if err != nil { + return err + } + + return nil + }, +} + +func init() { + listFilter = filterCmd(ListCmd) + listOutputFormat = outputFunc(ListCmd) + listConn = connectionCmd(ListCmd) + WorkflowCmd.AddCommand(ListCmd) +} diff --git a/cmd/workflow/purge.go b/cmd/workflow/purge.go new file mode 100644 index 000000000..5ddac021e --- /dev/null +++ b/cmd/workflow/purge.go @@ -0,0 +1,89 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "errors" + + "github.com/dapr/cli/pkg/workflow" + "github.com/dapr/kit/signals" + "github.com/spf13/cobra" +) + +var ( + flagPurgeOlderThan string + flagPurgeAll bool + flagPurgeConn *connFlag + schedulerNamespace string +) + +var PurgeCmd = &cobra.Command{ + Use: "purge", + Short: "Purge one or more workflow instances with a terminal state. Accepts a workflow instance ID argument or flags to purge multiple/all terminal instances. Also deletes all associated scheduler jobs.", + Args: func(cmd *cobra.Command, args []string) error { + switch { + case cmd.Flags().Changed("all-older-than"), + cmd.Flags().Changed("all"): + if len(args) > 0 { + return errors.New("no arguments are accepted when using purge all flags") + } + default: + if len(args) == 0 { + return errors.New("one or more workflow instance ID arguments are required when not using purge all flags") + } + } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := signals.Context() + + appID, err := getWorkflowAppID(cmd) + if err != nil { + return err + } + + opts := workflow.PurgeOptions{ + KubernetesMode: flagKubernetesMode, + Namespace: flagDaprNamespace, + SchedulerNamespace: schedulerNamespace, + AppID: appID, + InstanceIDs: args, + All: flagPurgeAll, + ConnectionString: flagPurgeConn.connectionString, + TableName: flagPurgeConn.tableName, + } + + if cmd.Flags().Changed("all-older-than") { + opts.AllOlderThan, err = parseWorkflowDurationTimestamp(flagPurgeOlderThan, true) + if err != nil { + return err + } + } + + return workflow.Purge(ctx, opts) + }, +} + +func init() { + PurgeCmd.Flags().StringVar(&flagPurgeOlderThan, "all-older-than", "", "Purge workflow instances older than the specified Go duration or timestamp, e.g., '24h' or '2023-01-02T15:04:05Z'.") + PurgeCmd.Flags().BoolVar(&flagPurgeAll, "all", false, "Purge all workflow instances in a terminal state. Use with caution.") + PurgeCmd.MarkFlagsMutuallyExclusive("all-older-than", "all") + + PurgeCmd.Flags().StringVar(&schedulerNamespace, "scheduler-namespace", "dapr-system", "Kubernetes namespace where the scheduler is deployed, only relevant if --kubernetes is set") + + flagPurgeConn = connectionCmd(PurgeCmd) + + WorkflowCmd.AddCommand(PurgeCmd) +} diff --git a/cmd/workflow/raiseevent.go b/cmd/workflow/raiseevent.go new file mode 100644 index 000000000..cc533a0a1 --- /dev/null +++ b/cmd/workflow/raiseevent.go @@ -0,0 +1,74 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "errors" + "os" + "strings" + + "github.com/dapr/cli/pkg/print" + "github.com/dapr/cli/pkg/workflow" + "github.com/dapr/kit/signals" + "github.com/spf13/cobra" +) + +var ( + flagRaiseEventInput *inputFlag +) + +var RaiseEventCmd = &cobra.Command{ + Use: "raise-event", + Short: "Raise an event for a workflow waiting for an external event. Expects a single argument '/'.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := signals.Context() + + split := strings.Split(args[0], "/") + if len(split) != 2 { + return errors.New("the argument must be in the format '/'") + } + instanceID := split[0] + eventName := split[1] + + appID, err := getWorkflowAppID(cmd) + if err != nil { + return err + } + + opts := workflow.RaiseEventOptions{ + KubernetesMode: flagKubernetesMode, + Namespace: flagDaprNamespace, + AppID: appID, + InstanceID: instanceID, + Name: eventName, + Input: flagRaiseEventInput.input, + } + + if err = workflow.RaiseEvent(ctx, opts); err != nil { + print.FailureStatusEvent(os.Stdout, err.Error()) + os.Exit(1) + } + + print.InfoStatusEvent(os.Stdout, "Workflow '%s' raised event '%s' successfully", instanceID, eventName) + + return nil + }, +} + +func init() { + flagRaiseEventInput = inputCmd(RaiseEventCmd) + + WorkflowCmd.AddCommand(RaiseEventCmd) +} diff --git a/cmd/workflow/rerun.go b/cmd/workflow/rerun.go new file mode 100644 index 000000000..29b85ba7a --- /dev/null +++ b/cmd/workflow/rerun.go @@ -0,0 +1,76 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/dapr/cli/pkg/print" + "github.com/dapr/cli/pkg/workflow" + "github.com/dapr/kit/ptr" + "github.com/dapr/kit/signals" +) + +var ( + flagReRunEventID uint32 + flagReRunNewInstanceID string + flagReRunInput *inputFlag +) + +var ReRunCmd = &cobra.Command{ + Use: "rerun [instance ID]", + Short: "ReRun a workflow instance from the beginning or a specific event. Optionally, a new instance ID and input to the starting event can be provided.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := signals.Context() + + appID, err := getWorkflowAppID(cmd) + if err != nil { + return err + } + + opts := workflow.ReRunOptions{ + KubernetesMode: flagKubernetesMode, + Namespace: flagDaprNamespace, + AppID: appID, + InstanceID: args[0], + Input: flagReRunInput.input, + EventID: flagReRunEventID, + } + + if cmd.Flags().Changed("new-instance-id") { + opts.NewInstanceID = ptr.Of(flagReRunNewInstanceID) + } + + id, err := workflow.ReRun(ctx, opts) + if err != nil { + print.FailureStatusEvent(os.Stdout, err.Error()) + os.Exit(1) + } + + print.InfoStatusEvent(os.Stdout, "Rerunning workflow instance: %s", id) + + return nil + }, +} + +func init() { + flagReRunInput = inputCmd(ReRunCmd) + ReRunCmd.Flags().StringVar(&flagReRunNewInstanceID, "new-instance-id", "", "Optional new ID for the re-run workflow instance. If not provided, a new ID will be generated.") + ReRunCmd.Flags().Uint32VarP(&flagReRunEventID, "event-id", "e", 0, "The event ID from which to re-run the workflow. If not provided, the workflow will re-run from the beginning.") + + WorkflowCmd.AddCommand(ReRunCmd) +} diff --git a/cmd/workflow/resume.go b/cmd/workflow/resume.go new file mode 100644 index 000000000..68085181c --- /dev/null +++ b/cmd/workflow/resume.go @@ -0,0 +1,64 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "os" + + "github.com/dapr/cli/pkg/print" + "github.com/dapr/cli/pkg/workflow" + "github.com/dapr/kit/signals" + "github.com/spf13/cobra" +) + +var ( + flagResumeReason string +) + +var ResumeCmd = &cobra.Command{ + Use: "resume", + Short: "Resume a workflow that is suspended.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := signals.Context() + + appID, err := getWorkflowAppID(cmd) + if err != nil { + return err + } + + opts := workflow.ResumeOptions{ + KubernetesMode: flagKubernetesMode, + Namespace: flagDaprNamespace, + AppID: appID, + InstanceID: args[0], + Reason: flagResumeReason, + } + + if err = workflow.Resume(ctx, opts); err != nil { + print.FailureStatusEvent(os.Stdout, err.Error()) + os.Exit(1) + } + + print.InfoStatusEvent(os.Stdout, "Workflow '%s' resumed successfully", args[0]) + + return nil + }, +} + +func init() { + ResumeCmd.Flags().StringVarP(&flagResumeReason, "reason", "r", "", "Reason for resuming the workflow") + + WorkflowCmd.AddCommand(ResumeCmd) +} diff --git a/cmd/workflow/run.go b/cmd/workflow/run.go new file mode 100644 index 000000000..2536def0f --- /dev/null +++ b/cmd/workflow/run.go @@ -0,0 +1,77 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/dapr/cli/pkg/print" + "github.com/dapr/cli/pkg/workflow" + "github.com/dapr/kit/signals" +) + +var ( + flagRunInstanceID *instanceIDFlag + flagRunInput *inputFlag + flagRunStartTime string +) + +var RunCmd = &cobra.Command{ + Use: "run", + Short: "Run a workflow instance based on a given workflow name. Accepts a single argument, the workflow name.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := signals.Context() + + appID, err := getWorkflowAppID(cmd) + if err != nil { + return err + } + + opts := workflow.RunOptions{ + KubernetesMode: flagKubernetesMode, + Namespace: flagDaprNamespace, + AppID: appID, + Name: args[0], + InstanceID: flagRunInstanceID.instanceID, + Input: flagRunInput.input, + } + + if cmd.Flags().Changed("start-time") { + opts.StartTime, err = parseWorkflowDurationTimestamp(flagRunStartTime, false) + if err != nil { + return err + } + } + + id, err := workflow.Run(ctx, opts) + if err != nil { + print.FailureStatusEvent(os.Stdout, err.Error()) + os.Exit(1) + } + + print.InfoStatusEvent(os.Stdout, "Workflow instance started successfully: %s", id) + + return nil + }, +} + +func init() { + flagRunInstanceID = instanceIDCmd(RunCmd) + flagRunInput = inputCmd(RunCmd) + RunCmd.Flags().StringVarP(&flagRunStartTime, "start-time", "s", "", "Optional start time for the workflow in RFC3339 or Go duration string format. If not provided, the workflow starts immediately. A duration of '0s', or any start time, will cause the command to not wait for the command to start") + WorkflowCmd.AddCommand(RunCmd) +} diff --git a/cmd/workflow/suspend.go b/cmd/workflow/suspend.go new file mode 100644 index 000000000..1023a58bb --- /dev/null +++ b/cmd/workflow/suspend.go @@ -0,0 +1,64 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "os" + + "github.com/dapr/cli/pkg/print" + "github.com/dapr/cli/pkg/workflow" + "github.com/dapr/kit/signals" + "github.com/spf13/cobra" +) + +var ( + flagSuspendReason string +) + +var SuspendCmd = &cobra.Command{ + Use: "suspend", + Short: "Suspend a workflow in progress.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := signals.Context() + + appID, err := getWorkflowAppID(cmd) + if err != nil { + return err + } + + opts := workflow.SuspendOptions{ + KubernetesMode: flagKubernetesMode, + Namespace: flagDaprNamespace, + AppID: appID, + InstanceID: args[0], + Reason: flagSuspendReason, + } + + if err = workflow.Suspend(ctx, opts); err != nil { + print.FailureStatusEvent(os.Stdout, err.Error()) + os.Exit(1) + } + + print.InfoStatusEvent(os.Stdout, "Workflow '%s' suspended successfully", args[0]) + + return nil + }, +} + +func init() { + SuspendCmd.Flags().StringVarP(&flagResumeReason, "reason", "r", "", "Reason for resuming the workflow") + + WorkflowCmd.AddCommand(SuspendCmd) +} diff --git a/cmd/workflow/terminate.go b/cmd/workflow/terminate.go new file mode 100644 index 000000000..2c4823932 --- /dev/null +++ b/cmd/workflow/terminate.go @@ -0,0 +1,69 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "os" + + "github.com/dapr/cli/pkg/print" + "github.com/dapr/cli/pkg/workflow" + "github.com/dapr/kit/signals" + "github.com/spf13/cobra" +) + +var ( + flagTerminateOutput string +) + +var TerminateCmd = &cobra.Command{ + Use: "terminate", + Short: "Terminate a workflow in progress.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := signals.Context() + + appID, err := getWorkflowAppID(cmd) + if err != nil { + return err + } + + var output *string + if cmd.Flags().Changed("output") { + output = &flagTerminateOutput + } + + opts := workflow.TerminateOptions{ + KubernetesMode: flagKubernetesMode, + Namespace: flagDaprNamespace, + AppID: appID, + InstanceID: args[0], + Output: output, + } + + if err = workflow.Terminate(ctx, opts); err != nil { + print.FailureStatusEvent(os.Stdout, err.Error()) + os.Exit(1) + } + + print.InfoStatusEvent(os.Stdout, "Workflow '%s' terminated successfully", args[0]) + + return nil + }, +} + +func init() { + TerminateCmd.Flags().StringVarP(&flagTerminateOutput, "output", "o", "", "Optional output data for the workflow in JSON string format.") + + WorkflowCmd.AddCommand(TerminateCmd) +} diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go new file mode 100644 index 000000000..3174792ed --- /dev/null +++ b/cmd/workflow/workflow.go @@ -0,0 +1,282 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/dapr/cli/pkg/kubernetes" + "github.com/dapr/cli/pkg/standalone" + "github.com/dapr/cli/pkg/workflow" + "github.com/dapr/kit/ptr" + kittime "github.com/dapr/kit/time" +) + +const ( + outputFormatShort = "short" + outputFormatWide = "wide" + outputFormatYAML = "yaml" + outputFormatJSON = "json" +) + +var ( + flagKubernetesMode bool + flagDaprNamespace string + flagAppID string +) + +var WorkflowCmd = &cobra.Command{ + Use: "workflow", + Short: "Workflow management commands. Use -k to target a Kubernetes Dapr cluster.", + Aliases: []string{"wf"}, +} + +func init() { + WorkflowCmd.PersistentFlags().BoolVarP(&flagKubernetesMode, "kubernetes", "k", false, "Target a Kubernetes dapr installation") + WorkflowCmd.PersistentFlags().StringVarP(&flagDaprNamespace, "namespace", "n", "default", "Namespace to perform workflow operation on") + WorkflowCmd.PersistentFlags().StringVarP(&flagAppID, "app-id", "a", "", "The app ID owner of the workflow instance") +} + +func outputFunc(cmd *cobra.Command) *string { + outputs := []string{ + outputFormatShort, + outputFormatWide, + outputFormatYAML, + outputFormatJSON, + } + + var outputFormat string + cmd.Flags().StringVarP(&outputFormat, "output", "o", outputFormatShort, fmt.Sprintf("Output format. One of %s", + strings.Join(outputs, ", ")), + ) + + pre := cmd.PreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + if !slices.Contains(outputs, outputFormat) { + return errors.New("invalid value for --output. Supported values are 'short', 'wide', 'yaml', 'json'.") + } + + if pre != nil { + return pre(cmd, args) + } + return nil + } + + return &outputFormat +} + +func getWorkflowAppID(cmd *cobra.Command) (string, error) { + if cmd.Flags().Changed("app-id") { + return flagAppID, nil + } + + var errRequired = fmt.Errorf("the app ID is required when there are multiple Dapr instances. Please specify it using the --app-id flag") + var errNotFound = fmt.Errorf("no Dapr instances found. Please ensure that Dapr is running") + + if flagKubernetesMode { + list, err := kubernetes.List(flagDaprNamespace) + if err != nil { + return "", err + } + + if len(list) == 0 { + return "", errNotFound + } + + if len(list) != 1 { + return "", errRequired + } + + return list[0].AppID, nil + } + + list, err := standalone.List() + if err != nil { + return "", err + } + + if len(list) == 0 { + return "", errNotFound + } + + if len(list) != 1 { + return "", errRequired + } + + return list[0].AppID, nil +} + +func parseWorkflowDurationTimestamp(str string, durationPast bool) (*time.Time, error) { + dur, err := time.ParseDuration(str) + if err == nil { + if durationPast { + dur = -dur + } + return ptr.Of(time.Now().Add(dur)), nil + } + + ts, err := kittime.ParseTime(str, nil) + if err != nil { + return nil, err + } + + return ptr.Of(ts), nil +} + +func filterCmd(cmd *cobra.Command) *workflow.Filter { + filter := new(workflow.Filter) + + var ( + name string + status string + maxAge string + + listStatuses = []string{ + "RUNNING", + "COMPLETED", + "CONTINUED_AS_NEW", + "FAILED", + "CANCELED", + "TERMINATED", + "PENDING", + "SUSPENDED", + } + ) + + cmd.Flags().StringVarP(&name, "filter-name", "w", "", "Filter only the workflows with the given name") + cmd.Flags().StringVarP(&status, "filter-status", "s", "", "Filter only the workflows with the given runtime status. One of "+strings.Join(listStatuses, ", ")) + cmd.Flags().StringVarP(&maxAge, "filter-max-age", "m", "", "Filter only the workflows started within the given duration or timestamp. Examples: 300ms, 1.5h or 2h45m, 2023-01-02T15:04:05 or 2023-01-02") + + pre := cmd.PreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("filter-name") { + filter.Name = &name + } + if cmd.Flags().Changed("filter-status") { + if !slices.Contains(listStatuses, status) { + return errors.New("invalid value for --filter-status. Supported values are " + strings.Join(listStatuses, ", ")) + } + filter.Status = &status + } + + if cmd.Flags().Changed("filter-max-age") { + var err error + filter.MaxAge, err = parseWorkflowDurationTimestamp(maxAge, true) + if err != nil { + return err + } + } + + if pre != nil { + return pre(cmd, args) + } + + return nil + } + + return filter +} + +type connFlag struct { + connectionString *string + tableName *string +} + +func connectionCmd(cmd *cobra.Command) *connFlag { + var ( + flagConnectionString string + flagTableName string + ) + + cmd.Flags().StringVarP(&flagConnectionString, "connection-string", "c", "", "The connection string used to connect and authenticate to the actor state store") + cmd.Flags().StringVarP(&flagTableName, "table-name", "t", "", "The name of the table or collection which is used as the actor state store") + + var cflag connFlag + pre := cmd.PreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("connection-string") { + cflag.connectionString = &flagConnectionString + } + + if cmd.Flags().Changed("table-name") { + cflag.tableName = &flagTableName + } + + if pre != nil { + return pre(cmd, args) + } + + return nil + } + + return &cflag +} + +type instanceIDFlag struct { + instanceID *string +} + +func instanceIDCmd(cmd *cobra.Command) *instanceIDFlag { + var instanceID string + iFlag := new(instanceIDFlag) + + cmd.Flags().StringVarP(&instanceID, "instance-id", "i", "", "The target workflow instance ID.") + + pre := cmd.PreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("instance-id") { + iFlag.instanceID = &instanceID + } + + if pre != nil { + return pre(cmd, args) + } + + return nil + } + + return iFlag +} + +type inputFlag struct { + input *string +} + +func inputCmd(cmd *cobra.Command) *inputFlag { + var input string + iFlag := new(inputFlag) + + cmd.Flags().StringVarP(&input, "input", "x", "", "Optional input data for the new workflow instance. Accepts a JSON string.") + + pre := cmd.PreRunE + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("input") { + iFlag.input = &input + } + + if pre != nil { + return pre(cmd, args) + } + + return nil + } + + return iFlag +} diff --git a/go.mod b/go.mod index 6cee22c95..3b3bef80a 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/Pallinder/sillyname-go v0.0.0-20130730142914-97aeae9e6ba1 github.com/briandowns/spinner v1.19.0 github.com/dapr/dapr v1.16.0 + github.com/dapr/durabletask-go v0.10.0 github.com/dapr/go-sdk v1.13.0 github.com/dapr/kit v0.16.1 github.com/diagridio/go-etcd-cron v0.9.1 @@ -17,18 +18,24 @@ require ( github.com/gocarina/gocsv v0.0.0-20220927221512-ad3251f9fa25 github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/go-version v1.6.0 + github.com/jackc/pgx/v5 v5.7.4 github.com/kolesnikovae/go-winjob v1.0.0 + github.com/mattn/go-sqlite3 v1.14.22 + github.com/microsoft/go-mssqldb v1.6.0 github.com/mitchellh/go-ps v1.0.0 github.com/nightlyone/lockfile v1.0.0 github.com/olekukonko/tablewriter v0.0.5 github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/redis/go-redis/v9 v9.6.3 github.com/shirou/gopsutil v3.21.11+incompatible + github.com/sijms/go-ora/v2 v2.8.22 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/spf13/viper v1.13.0 github.com/stretchr/testify v1.10.0 go.etcd.io/etcd/client/v3 v3.5.21 + go.mongodb.org/mongo-driver v1.14.0 golang.org/x/mod v0.25.0 golang.org/x/sys v0.33.0 google.golang.org/protobuf v1.36.6 @@ -79,9 +86,9 @@ require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/cyphar/filepath-securejoin v0.3.6 // indirect github.com/dapr/components-contrib v1.16.0 // indirect - github.com/dapr/durabletask-go v0.10.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.10.0 // indirect github.com/docker/cli v25.0.1+incompatible // indirect @@ -114,8 +121,11 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.22.0 // indirect github.com/google/gnostic-models v0.6.9 // indirect @@ -135,6 +145,9 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jhump/protoreflect v1.15.3 // indirect github.com/jmoiron/sqlx v1.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -165,6 +178,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/montanaflynn/stats v0.7.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -200,15 +214,18 @@ require ( github.com/tklauser/numcpus v0.6.1 // indirect github.com/tmc/langchaingo v0.1.13 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xlab/treeprint v1.2.0 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect github.com/zeebo/errs v1.4.0 // indirect go.etcd.io/etcd/api/v3 v3.5.21 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect - go.mongodb.org/mongo-driver v1.14.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect diff --git a/go.sum b/go.sum index fc262641b..b9fec0821 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,21 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0 h1:DRiANoJTiW6obBQe3SqZizkuV1PEgfiiGivmVocDy64= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0/go.mod h1:qLIye2hwb/ZouqhpSD9Zn3SJipvpEnz1Ywl3VUk9Y0s= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= @@ -102,6 +115,10 @@ github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh github.com/briandowns/spinner v1.19.0/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bufbuild/protocompile v0.6.0 h1:Uu7WiSQ6Yj9DbkdnOe7U4mNKp58y9WDMKDn28/ZlunY= github.com/bufbuild/protocompile v0.6.0/go.mod h1:YNP35qEYoYGme7QMtz5SBCoN4kL4g12jTtjuzRNdjpE= github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd h1:rFt+Y/IK1aEZkEHchZRSq9OQbsSzIT/OrI8YFFmRIng= @@ -168,6 +185,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/diagridio/go-etcd-cron v0.9.1 h1:KUfcceDtypL8s3hL0jD2ZoiIzjjXY6xDQ4kT1DJF4Ws= github.com/diagridio/go-etcd-cron v0.9.1/go.mod h1:CSzuxoCDFu+Gbds0RO73GE8CnmL5t85axiPLptsej3I= github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aBfCb7iqHmDEIp6fBvC/hQUddQfg+3qdYjwzaiP9Hnc= @@ -281,6 +300,12 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -314,6 +339,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k= github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -400,6 +427,14 @@ github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jhump/protoreflect v1.15.3 h1:6SFRuqU45u9hIZPJAoZ8c28T3nK64BNdp9w6jFonzls= github.com/jhump/protoreflect v1.15.3/go.mod h1:4ORHmSBmlCW8fh3xHmJMGyul1zNqZK4Elxc8qKP+p1k= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= @@ -472,6 +507,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/microsoft/go-mssqldb v1.6.0 h1:mM3gYdVwEPFrlg/Dvr2DNVEgYFG7L42l+dGc67NNNpc= +github.com/microsoft/go-mssqldb v1.6.0/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU= github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -503,6 +540,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/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/montanaflynn/stats v0.7.0 h1:r3y12KyNxj/Sb/iOE46ws+3mS1+MZca1wlHQFPsY/JU= +github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -585,6 +624,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/statsd_exporter v0.22.7 h1:7Pji/i2GuhK6Lu7DHrtTkFmNBCudCPT1pX2CziuyQR0= github.com/prometheus/statsd_exporter v0.22.7/go.mod h1:N/TevpjkIh9ccs6nuzY3jQn9dFqnUakOjnEuMPJJJnI= +github.com/redis/go-redis/v9 v9.6.3 h1:8Dr5ygF1QFXRxIH/m3Xg9MMG1rS8YCtAgosrsewT6i0= +github.com/redis/go-redis/v9 v9.6.3/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= @@ -600,6 +641,8 @@ github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKl github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sijms/go-ora/v2 v2.8.22 h1:3ABgRzVKxS439cEgSLjFKutIwOyhnyi4oOSBywEdOlU= +github.com/sijms/go-ora/v2 v2.8.22/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= @@ -654,6 +697,12 @@ github.com/tmc/langchaingo v0.1.13 h1:rcpMWBIi2y3B90XxfE4Ao8dhCQPVDMaNPnN5cGB1Ca github.com/tmc/langchaingo v0.1.13/go.mod h1:vpQ5NOIhpzxDfTZK9B6tf2GM/MoaHewPWM5KXXGh7hg= 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/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -663,10 +712,13 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI= @@ -733,6 +785,7 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -767,6 +820,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -799,9 +853,11 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -824,6 +880,7 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= @@ -874,6 +931,7 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220708085239-5a0f0661e09d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -892,6 +950,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -942,6 +1001,7 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/kubernetes/components.go b/pkg/kubernetes/components.go index a18352146..0b9a821ac 100644 --- a/pkg/kubernetes/components.go +++ b/pkg/kubernetes/components.go @@ -47,11 +47,11 @@ func PrintComponents(name, namespace, outputFormat string) error { return nil, err } - return listComponents(client, namespace) + return ListComponents(client, namespace) }, name, outputFormat) } -func listComponents(client versioned.Interface, namespace string) (*v1alpha1.ComponentList, error) { +func ListComponents(client versioned.Interface, namespace string) (*v1alpha1.ComponentList, error) { list, err := client.ComponentsV1alpha1().Components(namespace).List(meta_v1.ListOptions{}) // This means that the Dapr Components CRD is not installed and // therefore no component items exist. diff --git a/pkg/kubernetes/components_test.go b/pkg/kubernetes/components_test.go index dd7dc3880..420f3aff7 100644 --- a/pkg/kubernetes/components_test.go +++ b/pkg/kubernetes/components_test.go @@ -40,7 +40,7 @@ func TestComponents(t *testing.T) { name: "List one config", configName: "", outputFormat: "", - expectedOutput: "NAMESPACE NAME TYPE VERSION SCOPES CREATED AGE \ndefault appConfig state.redis v1 " + formattedNow + " 0s \n", + expectedOutput: "NAMESPACE NAME TYPE VERSION CREATED AGE \ndefault appConfig state.redis v1 " + formattedNow + " 0s \n", errString: "", errorExpected: false, k8sConfig: []v1alpha1.Component{ @@ -70,7 +70,7 @@ func TestComponents(t *testing.T) { name: "Filters out daprsystem", configName: "", outputFormat: "", - expectedOutput: "NAMESPACE NAME TYPE VERSION SCOPES CREATED AGE \ndefault appConfig state.redis v1 " + formattedNow + " 0s \n", + expectedOutput: "NAMESPACE NAME TYPE VERSION CREATED AGE \ndefault appConfig state.redis v1 " + formattedNow + " 0s \n", errString: "", errorExpected: false, k8sConfig: []v1alpha1.Component{ @@ -98,7 +98,7 @@ func TestComponents(t *testing.T) { name: "Name does match", configName: "appConfig", outputFormat: "list", - expectedOutput: "NAMESPACE NAME TYPE VERSION SCOPES CREATED AGE \ndefault appConfig state.redis v1 " + formattedNow + " 0s \n", + expectedOutput: "NAMESPACE NAME TYPE VERSION CREATED AGE \ndefault appConfig state.redis v1 " + formattedNow + " 0s \n", errString: "", errorExpected: false, k8sConfig: []v1alpha1.Component{ diff --git a/pkg/kubernetes/list.go b/pkg/kubernetes/list.go index a4db635dc..64a71c8b0 100644 --- a/pkg/kubernetes/list.go +++ b/pkg/kubernetes/list.go @@ -21,11 +21,13 @@ import ( // ListOutput represents the application ID, application port and creation time. type ListOutput struct { - Namespace string `csv:"NAMESPACE" json:"namespace" yaml:"namespace"` - AppID string `csv:"APP ID" json:"appId" yaml:"appId"` - AppPort string `csv:"APP PORT" json:"appPort" yaml:"appPort"` - Age string `csv:"AGE" json:"age" yaml:"age"` - Created string `csv:"CREATED" json:"created" yaml:"created"` + Namespace string `csv:"NAMESPACE" json:"namespace" yaml:"namespace"` + AppID string `csv:"APP ID" json:"appId" yaml:"appId"` + AppPort string `csv:"APP PORT" json:"appPort" yaml:"appPort"` + Age string `csv:"AGE" json:"age" yaml:"age"` + Created string `csv:"CREATED" json:"created" yaml:"created"` + DaprGRPCPort string `csv:"-" json:"-" yaml:"-"` + PodName string `csv:"-" json:"-" yaml:"-"` } // List outputs all the applications. @@ -46,17 +48,22 @@ func List(namespace string) ([]ListOutput, error) { if c.Name == "daprd" { lo := ListOutput{} for i, a := range c.Args { - if a == "--app-port" { + switch a { + case "--app-port": port := c.Args[i+1] lo.AppPort = port - } else if a == "--app-id" { + case "--app-id": id := c.Args[i+1] lo.AppID = id + case "--dapr-grpc-port": + port := c.Args[i+1] + lo.DaprGRPCPort = port } } lo.Namespace = p.GetNamespace() lo.Created = p.CreationTimestamp.Format("2006-01-02 15:04.05") lo.Age = age.GetAge(p.CreationTimestamp.Time) + lo.PodName = p.GetName() l = append(l, lo) } } diff --git a/pkg/scheduler/delete.go b/pkg/scheduler/delete.go index 342c9bf2b..2d5b15686 100644 --- a/pkg/scheduler/delete.go +++ b/pkg/scheduler/delete.go @@ -30,7 +30,7 @@ type DeleteOptions struct { } func Delete(ctx context.Context, opts DeleteOptions, keys ...string) error { - etcdClient, cancel, err := etcdClient(opts.KubernetesMode, opts.SchedulerNamespace) + etcdClient, cancel, err := EtcdClient(opts.KubernetesMode, opts.SchedulerNamespace) if err != nil { return err } diff --git a/pkg/scheduler/deleteall.go b/pkg/scheduler/deleteall.go index 25170fcb1..37edfcf91 100644 --- a/pkg/scheduler/deleteall.go +++ b/pkg/scheduler/deleteall.go @@ -25,7 +25,7 @@ import ( ) func DeleteAll(ctx context.Context, opts DeleteOptions, key string) error { - etcdClient, cancel, err := etcdClient(opts.KubernetesMode, opts.SchedulerNamespace) + etcdClient, cancel, err := EtcdClient(opts.KubernetesMode, opts.SchedulerNamespace) if err != nil { return err } diff --git a/pkg/scheduler/exportimport.go b/pkg/scheduler/exportimport.go index a54ae95b4..957ead1ab 100644 --- a/pkg/scheduler/exportimport.go +++ b/pkg/scheduler/exportimport.go @@ -46,7 +46,7 @@ func Export(ctx context.Context, opts ExportImportOptions) error { return err } - client, cancel, err := etcdClient(opts.KubernetesMode, opts.SchedulerNamespace) + client, cancel, err := EtcdClient(opts.KubernetesMode, opts.SchedulerNamespace) if err != nil { return err } @@ -98,7 +98,7 @@ func Export(ctx context.Context, opts ExportImportOptions) error { } func Import(ctx context.Context, opts ExportImportOptions) error { - client, cancel, err := etcdClient(opts.KubernetesMode, opts.SchedulerNamespace) + client, cancel, err := EtcdClient(opts.KubernetesMode, opts.SchedulerNamespace) if err != nil { return err } diff --git a/pkg/scheduler/get.go b/pkg/scheduler/get.go index 5613111e0..28506918f 100644 --- a/pkg/scheduler/get.go +++ b/pkg/scheduler/get.go @@ -38,7 +38,7 @@ func Get(ctx context.Context, opts GetOptions, keys ...string) ([]*ListOutput, e } func GetWide(ctx context.Context, opts GetOptions, keys ...string) ([]*ListOutputWide, error) { - etcdClient, cancel, err := etcdClient(opts.KubernetesMode, opts.SchedulerNamespace) + etcdClient, cancel, err := EtcdClient(opts.KubernetesMode, opts.SchedulerNamespace) if err != nil { return nil, err } diff --git a/pkg/scheduler/list.go b/pkg/scheduler/list.go index bd4854020..9f44d5fd1 100644 --- a/pkg/scheduler/list.go +++ b/pkg/scheduler/list.go @@ -102,7 +102,7 @@ func ListWide(ctx context.Context, opts ListOptions) ([]*ListOutputWide, error) } func ListJobs(ctx context.Context, opts ListOptions) ([]*JobCount, error) { - etcdClient, cancel, err := etcdClient(opts.KubernetesMode, opts.SchedulerNamespace) + etcdClient, cancel, err := EtcdClient(opts.KubernetesMode, opts.SchedulerNamespace) if err != nil { return nil, err } diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index 8281980a2..784ea8371 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -192,7 +192,7 @@ func parseJobKey(key string) (*jobKey, error) { } } -func etcdClient(kubernetesMode bool, schedulerNamespace string) (*clientv3.Client, context.CancelFunc, error) { +func EtcdClient(kubernetesMode bool, schedulerNamespace string) (*clientv3.Client, context.CancelFunc, error) { var etcdClient *clientv3.Client var err error if kubernetesMode { diff --git a/pkg/standalone/list.go b/pkg/standalone/list.go index da25807b0..2a555b86d 100644 --- a/pkg/standalone/list.go +++ b/pkg/standalone/list.go @@ -14,6 +14,7 @@ limitations under the License. package standalone import ( + "path/filepath" "strconv" "strings" "time" @@ -30,23 +31,24 @@ import ( // ListOutput represents the application ID, application port and creation time. type ListOutput struct { - AppID string `csv:"APP ID" json:"appId" yaml:"appId"` - HTTPPort int `csv:"HTTP PORT" json:"httpPort" yaml:"httpPort"` - GRPCPort int `csv:"GRPC PORT" json:"grpcPort" yaml:"grpcPort"` - AppPort int `csv:"APP PORT" json:"appPort" yaml:"appPort"` - MetricsEnabled bool `csv:"-" json:"metricsEnabled" yaml:"metricsEnabled"` // Not displayed in table, consumed by dashboard. - Command string `csv:"COMMAND" json:"command" yaml:"command"` - Age string `csv:"AGE" json:"age" yaml:"age"` - Created string `csv:"CREATED" json:"created" yaml:"created"` - DaprdPID int `csv:"DAPRD PID" json:"daprdPid" yaml:"daprdPid"` - CliPID int `csv:"CLI PID" json:"cliPid" yaml:"cliPid"` - AppPID int `csv:"APP PID" json:"appPid" yaml:"appPid"` - MaxRequestBodySize int `csv:"-" json:"maxRequestBodySize" yaml:"maxRequestBodySize"` // Additional field, not displayed in table. - HTTPReadBufferSize int `csv:"-" json:"httpReadBufferSize" yaml:"httpReadBufferSize"` // Additional field, not displayed in table. - RunTemplatePath string `csv:"RUN_TEMPLATE_PATH" json:"runTemplatePath" yaml:"runTemplatePath"` - AppLogPath string `csv:"APP_LOG_PATH" json:"appLogPath" yaml:"appLogPath"` - DaprDLogPath string `csv:"DAPRD_LOG_PATH" json:"daprdLogPath" yaml:"daprdLogPath"` - RunTemplateName string `json:"runTemplateName" yaml:"runTemplateName"` // specifically omitted in csv output. + AppID string `csv:"APP ID" json:"appId" yaml:"appId"` + HTTPPort int `csv:"HTTP PORT" json:"httpPort" yaml:"httpPort"` + GRPCPort int `csv:"GRPC PORT" json:"grpcPort" yaml:"grpcPort"` + AppPort int `csv:"APP PORT" json:"appPort" yaml:"appPort"` + MetricsEnabled bool `csv:"-" json:"metricsEnabled" yaml:"metricsEnabled"` // Not displayed in table, consumed by dashboard. + Command string `csv:"COMMAND" json:"command" yaml:"command"` + Age string `csv:"AGE" json:"age" yaml:"age"` + Created string `csv:"CREATED" json:"created" yaml:"created"` + DaprdPID int `csv:"DAPRD PID" json:"daprdPid" yaml:"daprdPid"` + CliPID int `csv:"CLI PID" json:"cliPid" yaml:"cliPid"` + AppPID int `csv:"APP PID" json:"appPid" yaml:"appPid"` + MaxRequestBodySize int `csv:"-" json:"maxRequestBodySize" yaml:"maxRequestBodySize"` // Additional field, not displayed in table. + HTTPReadBufferSize int `csv:"-" json:"httpReadBufferSize" yaml:"httpReadBufferSize"` // Additional field, not displayed in table. + RunTemplatePath string `csv:"RUN_TEMPLATE_PATH" json:"runTemplatePath" yaml:"runTemplatePath"` + AppLogPath string `csv:"APP_LOG_PATH" json:"appLogPath" yaml:"appLogPath"` + DaprDLogPath string `csv:"DAPRD_LOG_PATH" json:"daprdLogPath" yaml:"daprdLogPath"` + RunTemplateName string `json:"runTemplateName" yaml:"runTemplateName"` // specifically omitted in csv output. + ResourcePaths []string `csv:"-" json:"-" yaml:"-"` } func (d *daprProcess) List() ([]ListOutput, error) { @@ -64,7 +66,7 @@ func List() ([]ListOutput, error) { // Populates the map if all data is available for the sidecar. for _, proc := range processes { - executable := strings.ToLower(proc.Executable()) + executable := filepath.Base(strings.ToLower(proc.Executable())) if (executable == "daprd") || (executable == "daprd.exe") { procDetails, err := process.NewProcess(int32(proc.Pid())) //nolint:gosec if err != nil { @@ -81,15 +83,32 @@ func List() ([]ListOutput, error) { continue } + var resourcePaths []string + for i, item := range cmdLineItems { + if strings.HasPrefix(item, "--resources-path") { + if strings.Contains(item, "=") { + resourcePaths = strings.SplitN(item, "=", 2)[1:] + } else { + resourcePaths = append(resourcePaths, cmdLineItems[i+1]) + } + } + } + // Parse command line arguments, example format for cmdLine `daprd --flag1 value1 --enable-flag2 --flag3 value3`. argumentsMap := make(map[string]string) for i := 1; i < len(cmdLineItems)-1; { - if !strings.HasPrefix(cmdLineItems[i+1], "--") { - argumentsMap[cmdLineItems[i]] = cmdLineItems[i+1] - i += 2 - } else { - argumentsMap[cmdLineItems[i]] = "" + split := strings.Split(cmdLineItems[i], "=") + if len(split) > 1 { + argumentsMap[split[0]] = split[1] i++ + } else { + if !strings.HasPrefix(cmdLineItems[i+1], "--") { + argumentsMap[cmdLineItems[i]] = cmdLineItems[i+1] + i += 2 + } else { + argumentsMap[cmdLineItems[i]] = "" + i++ + } } } @@ -167,6 +186,7 @@ func List() ([]ListOutput, error) { RunTemplateName: runTemplateName, AppLogPath: appLogPath, DaprDLogPath: daprdLogPath, + ResourcePaths: resourcePaths, } // filter only dashboard instance. diff --git a/pkg/workflow/db/db.go b/pkg/workflow/db/db.go new file mode 100644 index 000000000..5cfac217a --- /dev/null +++ b/pkg/workflow/db/db.go @@ -0,0 +1,19 @@ +/* +Copyright 2025 The Dapr 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 db + +type ListOptions struct { + Namespace string + AppID string +} diff --git a/pkg/workflow/db/mongo.go b/pkg/workflow/db/mongo.go new file mode 100644 index 000000000..a13e4484d --- /dev/null +++ b/pkg/workflow/db/mongo.go @@ -0,0 +1,79 @@ +/* +Copyright 2025 The Dapr 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 db + +import ( + "context" + "fmt" + "regexp" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.mongodb.org/mongo-driver/mongo/readpref" +) + +func Mongo(ctx context.Context, uri string) (*mongo.Client, error) { + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) + if err != nil { + return nil, err + } + if err := client.Ping(ctx, readpref.Primary()); err != nil { + _ = client.Disconnect(ctx) + return nil, err + } + return client, nil +} + +func ListMongo(ctx context.Context, db *mongo.Database, collection string, opts ListOptions) ([]string, error) { + coll := db.Collection(collection) + + ns := regexp.QuoteMeta(opts.Namespace) + app := regexp.QuoteMeta(opts.AppID) + + prefix := fmt.Sprintf("%s\\|\\|dapr\\.internal\\.%s\\.%s\\.workflow\\|\\|", app, ns, app) + suffix := "\\|\\|metadata" + regex := fmt.Sprintf("^%s.*%s$", prefix, suffix) + + filter := bson.M{ + "key": bson.M{ + "$regex": regex, + "$options": "", + }, + } + + findOpts := options.Find().SetProjection(bson.M{"_id": 0, "key": 1}) + + cur, err := coll.Find(ctx, filter, findOpts) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + var keys []string + for cur.Next(ctx) { + var doc struct { + Key string `bson:"key"` + } + if err := cur.Decode(&doc); err != nil { + return nil, err + } + keys = append(keys, doc.Key) + } + if err := cur.Err(); err != nil { + return nil, err + } + + return keys, nil +} diff --git a/pkg/workflow/db/redis.go b/pkg/workflow/db/redis.go new file mode 100644 index 000000000..1f8718fcd --- /dev/null +++ b/pkg/workflow/db/redis.go @@ -0,0 +1,62 @@ +/* +Copyright 2025 The Dapr 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 db + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" +) + +func Redis(ctx context.Context, url string) (*redis.Client, error) { + opt, err := redis.ParseURL(url) + if err != nil { + return nil, err + + } + + rdb := redis.NewClient(opt) + if err := rdb.Ping(ctx).Err(); err != nil { + return nil, err + } + + return rdb, nil +} + +func ListRedis(ctx context.Context, rdb *redis.Client, opts ListOptions) ([]string, error) { + pattern := fmt.Sprintf("%s||dapr.internal.%s.%s.workflow||*||metadata", + opts.AppID, opts.Namespace, opts.AppID) + + var ( + cursor uint64 + keys []string + ) + + const scanCount int64 = 1000 + + for { + res, nextCursor, err := rdb.Scan(ctx, cursor, pattern, scanCount).Result() + if err != nil { + return nil, err + } + keys = append(keys, res...) + cursor = nextCursor + if cursor == 0 { + break + } + } + + return keys, nil +} diff --git a/pkg/workflow/db/sql.go b/pkg/workflow/db/sql.go new file mode 100644 index 000000000..fd1b30dcc --- /dev/null +++ b/pkg/workflow/db/sql.go @@ -0,0 +1,64 @@ +/* +Copyright 2025 The Dapr 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 db + +import ( + "context" + "database/sql" + "fmt" + + _ "github.com/jackc/pgx/v5/stdlib" + _ "github.com/mattn/go-sqlite3" + _ "github.com/microsoft/go-mssqldb" + _ "github.com/sijms/go-ora/v2" +) + +func SQL(ctx context.Context, driver, connString string) (*sql.DB, error) { + db, err := sql.Open(driver, connString) + if err != nil { + return nil, err + } + if err := db.PingContext(ctx); err != nil { + return nil, err + } + return db, nil +} + +func ListSQL(ctx context.Context, db *sql.DB, table string, opts ListOptions) ([]string, error) { + query := fmt.Sprintf(`SELECT key FROM "%s" WHERE key LIKE ?;`, table) + like := opts.AppID + "||dapr.internal." + opts.Namespace + "." + opts.AppID + ".workflow||%||metadata" + + rows, err := db.QueryContext(ctx, query, like) + if err != nil { + return nil, err + } + + defer rows.Close() + + var keys []string + for rows.Next() { + var key string + if err := rows.Scan(&key); err != nil { + return nil, err + } + + keys = append(keys, key) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return keys, nil +} diff --git a/pkg/workflow/dclient/dclient.go b/pkg/workflow/dclient/dclient.go new file mode 100644 index 000000000..c94b8b15d --- /dev/null +++ b/pkg/workflow/dclient/dclient.go @@ -0,0 +1,220 @@ +/* +Copyright 2025 The Dapr 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 dclient + +import ( + "context" + "fmt" + "slices" + "strconv" + + "github.com/dapr/cli/pkg/kubernetes" + "github.com/dapr/cli/pkg/standalone" + "github.com/dapr/dapr/pkg/apis/components/v1alpha1" + "github.com/dapr/dapr/pkg/components/loader" + "github.com/dapr/go-sdk/client" + "github.com/dapr/kit/ptr" +) + +type Client struct { + Dapr client.Client + Cancel context.CancelFunc + StateStoreDriver string + ConnectionString *string + TableName *string +} + +func DaprClient(ctx context.Context, kubernetesMode bool, namespace, appID string) (*Client, error) { + client.SetLogger(nil) + + var client *Client + var err error + if kubernetesMode { + client, err = kube(namespace, appID) + } else { + client, err = stand(ctx, appID) + } + + return client, err +} + +func stand(ctx context.Context, appID string) (*Client, error) { + list, err := standalone.List() + if err != nil { + return nil, err + } + + var proc *standalone.ListOutput + for _, c := range list { + if c.AppID == appID { + proc = &c + break + } + } + + if proc == nil { + return nil, fmt.Errorf("Dapr app with id '%s' not found", appID) + } + + comps, err := loader.NewLocalLoader(appID, proc.ResourcePaths).Load(ctx) + if err != nil { + return nil, err + } + + c, err := clientFromComponents(comps, appID, strconv.Itoa(proc.GRPCPort)) + if err != nil { + return nil, err + } + c.Cancel = func() {} + + return c, nil +} + +func kube(namespace string, appID string) (*Client, error) { + list, err := kubernetes.List(namespace) + if err != nil { + return nil, err + } + + var pod *kubernetes.ListOutput + for _, p := range list { + if p.AppID == appID { + pod = &p + break + } + } + + if pod == nil { + return nil, fmt.Errorf("Dapr app with id '%s' not found in namespace %s", appID, namespace) + } + + config, _, err := kubernetes.GetKubeConfigClient() + if err != nil { + return nil, err + } + + port, err := strconv.Atoi(pod.DaprGRPCPort) + if err != nil { + return nil, err + } + + portForward, err := kubernetes.NewPortForward( + config, + namespace, + pod.PodName, + "localhost", + port, + port, + false, + ) + if err != nil { + return nil, err + } + + if err = portForward.Init(); err != nil { + return nil, err + } + + kclient, err := kubernetes.DaprClient() + if err != nil { + return nil, err + } + + comps, err := kubernetes.ListComponents(kclient, pod.Namespace) + if err != nil { + return nil, err + } + + c, err := clientFromComponents(comps.Items, appID, pod.DaprGRPCPort) + if err != nil { + portForward.Stop() + } + + c.Cancel = portForward.Stop + + return c, nil +} + +func clientFromComponents(comps []v1alpha1.Component, appID string, port string) (*Client, error) { + var comp *v1alpha1.Component + for _, c := range comps { + for _, meta := range c.Spec.Metadata { + if meta.Name == "actorStateStore" && meta.Value.String() == "true" { + comp = &c + break + } + } + } + + if comp == nil { + return nil, fmt.Errorf("no state store configured for app id %s", appID) + } + + driver, err := driverFromType(comp.Spec.Type) + if err != nil { + return nil, err + } + + client, err := client.NewClientWithAddress("localhost:" + port) + if err != nil { + return nil, err + } + + var tableName *string + for _, meta := range comp.Spec.Metadata { + switch meta.Name { + case "tableName": + tableName = ptr.Of(meta.Value.String()) + } + } + + return &Client{ + Dapr: client, + StateStoreDriver: driver, + TableName: tableName, + }, nil +} + +func driverFromType(v string) (string, error) { + switch v { + case "state.mysql": + return "mysql", nil + case "state.postgresql": + return "pgx", nil + case "state.sqlserver": + return "sqlserver", nil + case "state.sqlite": + return "sqlite3", nil + case "state.oracledatabase": + return "oracle", nil + case "state.cockroachdb": + return "pgx", nil + case "state.redis": + return "redis", nil + case "state.mongodb": + return "mongodb", nil + default: + return "", fmt.Errorf("unsupported state store type: %s", v) + } +} + +func IsSQLDriver(driver string) bool { + return slices.Contains([]string{ + "mysql", + "pgx", + "sqlserver", + "sqlite3", + "oracle", + }, driver) +} diff --git a/pkg/workflow/events.go b/pkg/workflow/events.go new file mode 100644 index 000000000..94e9eed86 --- /dev/null +++ b/pkg/workflow/events.go @@ -0,0 +1,112 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "context" + + "github.com/dapr/cli/pkg/workflow/dclient" + "github.com/dapr/durabletask-go/workflow" +) + +type RaiseEventOptions struct { + KubernetesMode bool + Namespace string + AppID string + InstanceID string + Name string + Input *string +} + +func RaiseEvent(ctx context.Context, opts RaiseEventOptions) error { + cli, err := dclient.DaprClient(ctx, opts.KubernetesMode, opts.Namespace, opts.AppID) + if err != nil { + return err + } + defer cli.Cancel() + + wf := workflow.NewClient(cli.Dapr.GrpcClientConn()) + + var wopts []workflow.RaiseEventOptions + if opts.Input != nil { + wopts = append(wopts, workflow.WithEventPayload(*opts.Input)) + } + + return wf.RaiseEvent(ctx, opts.InstanceID, opts.Name, wopts...) +} + +type SuspendOptions struct { + KubernetesMode bool + Namespace string + AppID string + InstanceID string + Reason string +} + +func Suspend(ctx context.Context, opts SuspendOptions) error { + cli, err := dclient.DaprClient(ctx, opts.KubernetesMode, opts.Namespace, opts.AppID) + if err != nil { + return err + } + defer cli.Cancel() + + wf := workflow.NewClient(cli.Dapr.GrpcClientConn()) + + return wf.SuspendWorkflow(ctx, opts.InstanceID, opts.Reason) +} + +type ResumeOptions struct { + KubernetesMode bool + Namespace string + AppID string + InstanceID string + Reason string +} + +func Resume(ctx context.Context, opts ResumeOptions) error { + cli, err := dclient.DaprClient(ctx, opts.KubernetesMode, opts.Namespace, opts.AppID) + if err != nil { + return err + } + defer cli.Cancel() + + wf := workflow.NewClient(cli.Dapr.GrpcClientConn()) + + return wf.ResumeWorkflow(ctx, opts.InstanceID, opts.Reason) +} + +type TerminateOptions struct { + KubernetesMode bool + Namespace string + AppID string + InstanceID string + Output *string +} + +func Terminate(ctx context.Context, opts TerminateOptions) error { + cli, err := dclient.DaprClient(ctx, opts.KubernetesMode, opts.Namespace, opts.AppID) + if err != nil { + return err + } + defer cli.Cancel() + + wf := workflow.NewClient(cli.Dapr.GrpcClientConn()) + + var wopts []workflow.TerminateOptions + if opts.Output != nil { + wopts = append(wopts, workflow.WithOutput(*opts.Output)) + } + + return wf.TerminateWorkflow(ctx, opts.InstanceID, wopts...) +} diff --git a/pkg/workflow/history.go b/pkg/workflow/history.go new file mode 100644 index 000000000..c6fb2722f --- /dev/null +++ b/pkg/workflow/history.go @@ -0,0 +1,517 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/dapr/cli/pkg/workflow/dclient" + "github.com/dapr/cli/utils" + "github.com/dapr/durabletask-go/api/protos" + "github.com/dapr/durabletask-go/workflow" + "github.com/dapr/go-sdk/client" + "github.com/dapr/kit/ptr" +) + +const maxHistoryEntries = 100 + +type HistoryOptions struct { + KubernetesMode bool + Namespace string + AppID string + InstanceID string +} + +type HistoryOutputWide struct { + Namespace string `csv:"-" json:"namespace,omitempty" yaml:"namespace,omitempty"` + AppID string `csv:"-" json:"appID" yaml:"appID"` + Play int `csv:"PLAY" json:"play" yaml:"play"` + Type string `csv:"TYPE" json:"type" yaml:"type"` + Name *string `csv:"NAME" json:"name" yaml:"name"` + EventID *int32 `csv:"EVENTID,omitempty" json:"eventId,omitempty" yaml:"eventId,omitempty"` + Timestamp time.Time `csv:"TIMESTAMP" json:"timestamp" yaml:"timestamp"` + Elapsed string `csv:"ELAPSED" json:"elapsed" yaml:"elapsed"` + Status string `csv:"STATUS" json:"status" yaml:"status"` + Details *string `csv:"DETAILS" json:"details" yaml:"details"` + Router *string `csv:"ROUTER,omitempty" json:"router,omitempty" yaml:"router,omitempty"` + ExecutionID *string `csv:"EXECUTION_ID,omitempty" json:"executionId,omitempty" yaml:"executionId,omitempty"` + + Attrs *string `csv:"ATTRS,omitempty" json:"attrs,omitempty" yaml:"attrs,omitempty"` +} + +type HistoryOutputShort struct { + Type string `csv:"TYPE" json:"type" yaml:"type"` + Name string `csv:"NAME" json:"name" yaml:"name"` + EventID string `csv:"EVENTID,omitempty" json:"eventId,omitempty" yaml:"eventId,omitempty"` + Elapsed string `csv:"ELAPSED" json:"elapsed" yaml:"elapsed"` + Status string `csv:"STATUS" json:"status" yaml:"status"` + Details string `csv:"DETAILS" json:"details" yaml:"details"` +} + +func HistoryShort(ctx context.Context, opts HistoryOptions) ([]*HistoryOutputShort, error) { + wide, err := HistoryWide(ctx, opts) + if err != nil { + return nil, err + } + + short := make([]*HistoryOutputShort, 0, len(wide)) + for _, w := range wide { + s := &HistoryOutputShort{ + Name: "-", + EventID: "-", + Type: w.Type, + Elapsed: w.Elapsed, + Status: w.Status, + Details: "-", + } + + if w.Name != nil { + s.Name = *w.Name + } + + if w.Details != nil { + s.Details = *w.Details + } + if w.EventID != nil { + s.EventID = fmt.Sprintf("%d", *w.EventID) + } + + short = append(short, s) + } + + return short, nil +} + +func HistoryWide(ctx context.Context, opts HistoryOptions) ([]*HistoryOutputWide, error) { + cli, err := dclient.DaprClient(ctx, opts.KubernetesMode, opts.Namespace, opts.AppID) + if err != nil { + return nil, err + } + defer cli.Cancel() + + history, err := fetchHistory(ctx, + cli.Dapr, + "dapr.internal."+opts.Namespace+"."+opts.AppID+".workflow", + opts.InstanceID, + ) + if err != nil { + return nil, err + } + + // Sort: EventId if both present, else Timestamp + sort.SliceStable(history, func(i, j int) bool { + ei, ej := history[i], history[j] + if ei.EventId > 0 && ej.EventId > 0 { + return ei.EventId < ej.EventId + } + ti, tj := ei.GetTimestamp().AsTime(), ej.GetTimestamp().AsTime() + if !ti.Equal(tj) { + return ti.Before(tj) + } + return ei.EventId < ej.EventId + }) + + var rows []*HistoryOutputWide + var prevTs time.Time + replay := 0 + + for idx, ev := range history { + ts := ev.GetTimestamp().AsTime() + if idx == 0 { + prevTs = ts + } + + if _, ok := ev.GetEventType().(*protos.HistoryEvent_OrchestratorStarted); ok { + replay++ + } + + row := &HistoryOutputWide{ + Namespace: opts.Namespace, + AppID: opts.AppID, + Play: replay, + Type: eventTypeName(ev), + Name: deriveName(ev), + Timestamp: ts.Truncate(time.Second), + Status: deriveStatus(ev), + Details: deriveDetails(history[0], ev), + } + + if idx == 0 { + row.Elapsed = "Age:" + utils.HumanizeDuration(time.Since(ts)) + } else { + row.Elapsed = utils.HumanizeDuration(ts.Sub(prevTs)) + } + + prevTs = ts + + if ev.EventId > 0 { + row.EventID = ptr.Of(ev.EventId) + } + row.Router = routerStr(ev.Router) + + switch t := ev.GetEventType().(type) { + case *protos.HistoryEvent_ExecutionStarted: + if t.ExecutionStarted.OrchestrationInstance != nil && + t.ExecutionStarted.OrchestrationInstance.ExecutionId != nil { + execID := t.ExecutionStarted.OrchestrationInstance.ExecutionId.Value + row.ExecutionID = &execID + } + if t.ExecutionStarted.Input != nil { + row.addAttr("input", trim(t.ExecutionStarted.Input, 120)) + } + if len(t.ExecutionStarted.Tags) > 0 { + row.addAttr("tags", flatTags(t.ExecutionStarted.Tags, 6)) + } + case *protos.HistoryEvent_TaskScheduled: + if row.EventID == nil { + row.EventID = ptr.Of(int32(0)) + } + if t.TaskScheduled.TaskExecutionId != "" { + row.ExecutionID = ptr.Of(t.TaskScheduled.TaskExecutionId) + } + if t.TaskScheduled.Input != nil { + row.addAttr("input", trim(t.TaskScheduled.Input, 120)) + } + case *protos.HistoryEvent_TaskCompleted: + row.addAttr("scheduledId", fmt.Sprintf("%d", t.TaskCompleted.TaskScheduledId)) + if t.TaskCompleted.TaskExecutionId != "" { + row.ExecutionID = ptr.Of(t.TaskCompleted.TaskExecutionId) + } + if t.TaskCompleted.Result != nil { + row.addAttr("result", trim(t.TaskCompleted.Result, 120)) + } + case *protos.HistoryEvent_TaskFailed: + row.addAttr("scheduledId", fmt.Sprintf("%d", t.TaskFailed.TaskScheduledId)) + if t.TaskFailed.TaskExecutionId != "" { + row.ExecutionID = ptr.Of(t.TaskFailed.TaskExecutionId) + } + if fd := t.TaskFailed.FailureDetails; fd != nil { + if fd.ErrorType != "" { + row.addAttr("errorType", fd.ErrorType) + } + if fd.ErrorMessage != "" { + row.addAttr("errorMsg", trim(wrapperspb.String(fd.ErrorMessage), 160)) + } + if fd.IsNonRetriable { + row.addAttr("nonRetriable", "true") + } + } + case *protos.HistoryEvent_TimerCreated: + if row.EventID == nil { + row.EventID = ptr.Of(int32(0)) + } + if t.TimerCreated.Name != nil { + row.addAttr("timerName", *t.TimerCreated.Name) + } + row.addAttr("fireAt", t.TimerCreated.FireAt.AsTime().Format(time.RFC3339)) + case *protos.HistoryEvent_TimerFired: + row.addAttr("timerId", fmt.Sprintf("%d", t.TimerFired.TimerId)) + row.addAttr("fireAt", t.TimerFired.FireAt.AsTime().Format(time.RFC3339)) + case *protos.HistoryEvent_EventRaised: + row.addAttr("eventName", t.EventRaised.Name) + if t.EventRaised.Input != nil { + row.addAttr("payload", trim(t.EventRaised.Input, 120)) + } + case *protos.HistoryEvent_EventSent: + row.addAttr("eventName", t.EventSent.Name) + if t.EventSent.Input != nil { + row.addAttr("payload", trim(t.EventSent.Input, 120)) + } + row.addAttr("targetInstance", t.EventSent.InstanceId) + case *protos.HistoryEvent_ExecutionCompleted: + if t.ExecutionCompleted.Result != nil { + row.addAttr("output", trim(t.ExecutionCompleted.Result, 160)) + } + if fd := t.ExecutionCompleted.FailureDetails; fd != nil { + if fd.ErrorType != "" { + row.addAttr("failureType", fd.ErrorType) + } + if fd.ErrorMessage != "" { + row.addAttr("failureMessage", trim(wrapperspb.String(fd.ErrorMessage), 160)) + } + } + } + + rows = append(rows, row) + } + + return rows, nil +} + +func fetchHistory(ctx context.Context, cl client.Client, actorType, instanceID string) ([]*protos.HistoryEvent, error) { + var events []*protos.HistoryEvent + for startIndex := 0; startIndex <= 1; startIndex++ { + if len(events) > 0 { + break + } + + for i := startIndex; i < maxHistoryEntries; i++ { + key := fmt.Sprintf("history-%06d", i) + + resp, err := cl.GetActorState(ctx, &client.GetActorStateRequest{ + ActorType: actorType, + ActorID: instanceID, + KeyName: key, + }) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return nil, err + } + break + } + + if resp == nil || len(resp.Data) == 0 { + break + } + + var event protos.HistoryEvent + if err = decodeKey(resp.Data, &event); err != nil { + return nil, fmt.Errorf("failed to decode history event %s: %w", key, err) + } + + events = append(events, &event) + } + } + + return events, nil +} + +func decodeKey(data []byte, item proto.Message) error { + if len(data) == 0 { + return fmt.Errorf("empty value") + } + + if err := protojson.Unmarshal(data, item); err == nil { + return nil + } + + if unquoted, err := unquoteJSON(data); err == nil { + if err := protojson.Unmarshal([]byte(unquoted), item); err == nil { + return nil + } + } + + if err := proto.Unmarshal(data, item); err == nil { + return nil + } + + return fmt.Errorf("unable to decode history event (len=%d)", len(data)) +} + +func unquoteJSON(data []byte) (string, error) { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return "", err + } + return s, nil +} + +func eventTypeName(h *protos.HistoryEvent) string { + switch h.GetEventType().(type) { + case *protos.HistoryEvent_ExecutionStarted: + return "ExecutionStarted" + case *protos.HistoryEvent_ExecutionCompleted: + return "ExecutionCompleted" + case *protos.HistoryEvent_ExecutionTerminated: + return "ExecutionTerminated" + case *protos.HistoryEvent_TaskScheduled: + return "TaskScheduled" + case *protos.HistoryEvent_TaskCompleted: + return "TaskCompleted" + case *protos.HistoryEvent_TaskFailed: + return "TaskFailed" + case *protos.HistoryEvent_SubOrchestrationInstanceCreated: + return "SubOrchCreated" + case *protos.HistoryEvent_SubOrchestrationInstanceCompleted: + return "SubOrchCompleted" + case *protos.HistoryEvent_SubOrchestrationInstanceFailed: + return "SubOrchFailed" + case *protos.HistoryEvent_TimerCreated: + return "TimerCreated" + case *protos.HistoryEvent_TimerFired: + return "TimerFired" + case *protos.HistoryEvent_OrchestratorStarted: + return "OrchestratorStarted" + case *protos.HistoryEvent_OrchestratorCompleted: + return "OrchestratorCompleted" + case *protos.HistoryEvent_EventSent: + return "EventSent" + case *protos.HistoryEvent_EventRaised: + return "EventRaised" + case *protos.HistoryEvent_GenericEvent: + return "GenericEvent" + case *protos.HistoryEvent_HistoryState: + return "HistoryState" + case *protos.HistoryEvent_ContinueAsNew: + return "ContinueAsNew" + case *protos.HistoryEvent_ExecutionSuspended: + return "ExecutionSuspended" + case *protos.HistoryEvent_ExecutionResumed: + return "ExecutionResumed" + case *protos.HistoryEvent_EntityOperationSignaled: + return "EntitySignaled" + case *protos.HistoryEvent_EntityOperationCalled: + return "EntityCalled" + case *protos.HistoryEvent_EntityOperationCompleted: + return "EntityCompleted" + case *protos.HistoryEvent_EntityOperationFailed: + return "EntityFailed" + case *protos.HistoryEvent_EntityLockRequested: + return "EntityLockRequested" + case *protos.HistoryEvent_EntityLockGranted: + return "EntityLockGranted" + case *protos.HistoryEvent_EntityUnlockSent: + return "EntityUnlockSent" + default: + return "Unknown" + } +} + +func deriveName(h *protos.HistoryEvent) *string { + switch t := h.GetEventType().(type) { + case *protos.HistoryEvent_TaskScheduled: + return ptr.Of(t.TaskScheduled.Name) + case *protos.HistoryEvent_TaskCompleted: + return nil + case *protos.HistoryEvent_TaskFailed: + return nil + case *protos.HistoryEvent_SubOrchestrationInstanceCreated: + return ptr.Of(t.SubOrchestrationInstanceCreated.Name) + case *protos.HistoryEvent_TimerCreated: + if t.TimerCreated.Name != nil { + return ptr.Of(*t.TimerCreated.Name) + } + case *protos.HistoryEvent_EventRaised: + return ptr.Of(t.EventRaised.Name) + case *protos.HistoryEvent_EventSent: + return ptr.Of(t.EventSent.Name) + case *protos.HistoryEvent_ExecutionStarted: + return ptr.Of(t.ExecutionStarted.Name) + } + return nil +} + +func deriveStatus(h *protos.HistoryEvent) string { + switch t := h.GetEventType().(type) { + case *protos.HistoryEvent_TaskFailed: + return "FAILED" + case *protos.HistoryEvent_ExecutionCompleted: + return (workflow.WorkflowMetadata{RuntimeStatus: t.ExecutionCompleted.OrchestrationStatus}).String() + case *protos.HistoryEvent_ExecutionTerminated: + return "TERMINATED" + case *protos.HistoryEvent_ExecutionSuspended: + return "SUSPENDED" + case *protos.HistoryEvent_ExecutionResumed: + return "RESUMED" + default: + return "RUNNING" + } +} + +func deriveDetails(first *protos.HistoryEvent, h *protos.HistoryEvent) *string { + switch t := h.GetEventType().(type) { + case *protos.HistoryEvent_TaskScheduled: + ver := "" + if t.TaskScheduled.Version != nil && t.TaskScheduled.Version.Value != "" { + ver = " v" + t.TaskScheduled.Version.Value + } + return ptr.Of(fmt.Sprintf("activity=%s%s", t.TaskScheduled.Name, ver)) + case *protos.HistoryEvent_TimerCreated: + return ptr.Of(fmt.Sprintf("fireAt=%s", t.TimerCreated.FireAt.AsTime().Format(time.RFC3339))) + case *protos.HistoryEvent_EventRaised: + return ptr.Of(fmt.Sprintf("event=%s", t.EventRaised.Name)) + case *protos.HistoryEvent_EventSent: + return ptr.Of(fmt.Sprintf("event=%s -> %s", t.EventSent.Name, t.EventSent.InstanceId)) + case *protos.HistoryEvent_ExecutionStarted: + return ptr.Of("orchestration start") + case *protos.HistoryEvent_OrchestratorStarted: + return ptr.Of("replay cycle start") + case *protos.HistoryEvent_TaskCompleted: + return ptr.Of(fmt.Sprintf("eventId=%d", t.TaskCompleted.TaskScheduledId)) + case *protos.HistoryEvent_ExecutionCompleted: + return ptr.Of(fmt.Sprintf("execDuration=%s", utils.HumanizeDuration(h.GetTimestamp().AsTime().Sub(first.GetTimestamp().AsTime())))) + default: + return nil + } +} + +func routerStr(rt *protos.TaskRouter) *string { + if rt == nil { + return nil + } + if rt.TargetAppID != nil { + return ptr.Of(fmt.Sprintf("%s->%s", rt.SourceAppID, *rt.TargetAppID)) + } + return ptr.Of(rt.SourceAppID) +} + +func (h *HistoryOutputWide) addAttr(key, val string) { + if val == "" { + return + } + if h.Attrs == nil { + h.Attrs = ptr.Of(key + "=" + val) + return + } + *h.Attrs += ";" + key + "=" + val +} + +func flatTags(tags map[string]string, max int) string { + i := 0 + var parts []string + for k, v := range tags { + parts = append(parts, fmt.Sprintf("%s=%s", k, v)) + i++ + if i >= max { + break + } + } + sort.Strings(parts) + s := strings.Join(parts, ",") + if len(tags) > max { + s += ",…" + } + return s +} + +func trim(ww *wrapperspb.StringValue, limit int) string { + if ww == nil { + return "" + } + + s, err := unquoteJSON([]byte(ww.Value)) + if err != nil { + s = ww.Value + } + + if limit <= 0 || len(s) <= limit { + return s + } + + r := []rune(s) + if len(r) <= limit { + return s + } + return string(r[:limit]) + "…" +} diff --git a/pkg/workflow/list.go b/pkg/workflow/list.go new file mode 100644 index 000000000..d3e7a7966 --- /dev/null +++ b/pkg/workflow/list.go @@ -0,0 +1,199 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/dapr/cli/pkg/workflow/dclient" + "github.com/dapr/durabletask-go/api" + "github.com/dapr/durabletask-go/api/protos" + "github.com/dapr/durabletask-go/workflow" + "github.com/dapr/go-sdk/client" + "github.com/dapr/kit/ptr" + "k8s.io/apimachinery/pkg/util/duration" +) + +type ListOptions struct { + KubernetesMode bool + Namespace string + AppID string + ConnectionString *string + TableName *string + Filter Filter +} + +type Filter struct { + Name *string + Status *string + MaxAge *time.Time + Terminal bool +} + +type ListOutputShort struct { + Namespace string `csv:"-" json:"namespace" yaml:"namespace"` + AppID string `csv:"-" json:"appID" yaml:"appID"` + Name string `csv:"NAME" json:"name" yaml:"name"` + InstanceID string `csv:"ID" json:"instanceID" yaml:"instanceID"` + RuntimeStatus string `csv:"STATUS" json:"runtimeStatus" yaml:"runtimeStatus"` + CustomStatus string `csv:"CUSTOM STATUS" json:"customStatus" yaml:"customStatus"` + Age string `csv:"AGE" json:"age" yaml:"age"` +} + +type ListOutputWide struct { + Namespace string `csv:"NAMESPACE" json:"namespace" yaml:"namespace"` + AppID string `csv:"APP ID" json:"appID" yaml:"appID"` + Name string `csv:"Name" json:"name" yaml:"name"` + InstanceID string `csv:"INSTANCE ID" json:"instanceID" yaml:"instanceID"` + Created time.Time `csv:"CREATED" json:"created" yaml:"created"` + LastUpdate time.Time `csv:"LAST UPDATE" json:"lastUpdate" yaml:"lastUpdate"` + RuntimeStatus string `csv:"STATUS" json:"runtimeStatus" yaml:"runtimeStatus"` + CustomStatus string `csv:"CUSTOM STATUS" json:"customStatus" yaml:"customStatus"` + FailureMessage string `csv:"FAILURE MESSAGE" json:"failureMessage" yaml:"failureMessage"` +} + +func ListShort(ctx context.Context, opts ListOptions) ([]*ListOutputShort, error) { + wide, err := ListWide(ctx, opts) + if err != nil { + return nil, err + } + + short := make([]*ListOutputShort, len(wide)) + for i, w := range wide { + short[i] = &ListOutputShort{ + Namespace: w.Namespace, + AppID: w.AppID, + Name: w.Name, + InstanceID: w.InstanceID, + Age: translateTimestampSince(w.Created), + RuntimeStatus: w.RuntimeStatus, + } + if len(w.CustomStatus) > 0 { + short[i].CustomStatus = w.CustomStatus + } + } + + return short, nil +} + +func ListWide(ctx context.Context, opts ListOptions) ([]*ListOutputWide, error) { + dclient, err := dclient.DaprClient(ctx, opts.KubernetesMode, opts.Namespace, opts.AppID) + if err != nil { + return nil, fmt.Errorf("failed to create Dapr client: %w", err) + } + defer dclient.Cancel() + + connString := opts.ConnectionString + if connString == nil { + connString = dclient.ConnectionString + } + tableName := opts.TableName + if tableName == nil { + tableName = dclient.TableName + } + + metaKeys, err := metakeys(ctx, DBOptions{ + Namespace: opts.Namespace, + AppID: opts.AppID, + Driver: dclient.StateStoreDriver, + ConnectionString: connString, + TableName: tableName, + }) + if err != nil { + return nil, err + } + + return list(ctx, metaKeys, dclient.Dapr, opts) +} + +func list(ctx context.Context, metaKeys []string, cl client.Client, opts ListOptions) ([]*ListOutputWide, error) { + wf := workflow.NewClient(cl.GrpcClientConn()) + + var listOutput []*ListOutputWide + for _, key := range metaKeys { + split := strings.Split(key, "||") + if len(split) != 4 { + continue + } + + instanceID := split[2] + + resp, err := wf.FetchWorkflowMetadata(ctx, instanceID) + if err != nil { + return nil, err + } + + if opts.Filter.Name != nil && resp.Name != *opts.Filter.Name { + continue + } + if opts.Filter.Status != nil && resp.String() != *opts.Filter.Status { + continue + } + if opts.Filter.MaxAge != nil && resp.CreatedAt.AsTime().Before(*opts.Filter.MaxAge) { + continue + } + // TODO: @joshvanl: add `WorkflowIsCompleted` func to workflow package. + //nolint:govet + if opts.Filter.Terminal && !api.OrchestrationMetadataIsComplete(ptr.Of(protos.OrchestrationMetadata(*resp))) { + continue + } + + wide := &ListOutputWide{ + Namespace: opts.Namespace, + AppID: opts.AppID, + Name: resp.Name, + InstanceID: instanceID, + Created: resp.CreatedAt.AsTime().Truncate(time.Second), + LastUpdate: resp.LastUpdatedAt.AsTime().Truncate(time.Second), + RuntimeStatus: resp.String(), + } + + if resp.CustomStatus != nil { + wide.CustomStatus = resp.CustomStatus.Value + } + + if resp.FailureDetails != nil { + wide.FailureMessage = strings.ReplaceAll( + strings.ReplaceAll( + resp.FailureDetails.GetErrorMessage(), + "\n", ""), + "\r", "") + } + + listOutput = append(listOutput, wide) + } + + sort.SliceStable(listOutput, func(i, j int) bool { + if listOutput[i].Created.IsZero() { + return false + } + if listOutput[j].Created.IsZero() { + return true + } + return listOutput[i].Created.Before(listOutput[j].Created) + }) + + return listOutput, nil +} + +func translateTimestampSince(timestamp time.Time) string { + if timestamp.IsZero() { + return "" + } + return duration.HumanDuration(time.Since(timestamp)) +} diff --git a/pkg/workflow/purge.go b/pkg/workflow/purge.go new file mode 100644 index 000000000..448930ebf --- /dev/null +++ b/pkg/workflow/purge.go @@ -0,0 +1,124 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "context" + "fmt" + "os" + "time" + + clientv3 "go.etcd.io/etcd/client/v3" + + "github.com/dapr/cli/pkg/print" + "github.com/dapr/cli/pkg/scheduler" + "github.com/dapr/cli/pkg/workflow/dclient" + "github.com/dapr/durabletask-go/workflow" +) + +type PurgeOptions struct { + KubernetesMode bool + Namespace string + SchedulerNamespace string + AppID string + InstanceIDs []string + AllOlderThan *time.Time + All bool + + ConnectionString *string + TableName *string +} + +func Purge(ctx context.Context, opts PurgeOptions) error { + cli, err := dclient.DaprClient(ctx, opts.KubernetesMode, opts.Namespace, opts.AppID) + if err != nil { + return err + } + defer cli.Cancel() + + var toPurge []string + + if len(opts.InstanceIDs) > 0 { + toPurge = opts.InstanceIDs + } else { + var list []*ListOutputWide + list, err = ListWide(ctx, ListOptions{ + KubernetesMode: opts.KubernetesMode, + Namespace: opts.Namespace, + AppID: opts.AppID, + ConnectionString: opts.ConnectionString, + TableName: opts.TableName, + Filter: Filter{ + Terminal: true, + }, + }) + if err != nil { + return err + } + + switch { + case opts.AllOlderThan != nil: + for _, w := range list { + if w.Created.Before(*opts.AllOlderThan) { + toPurge = append(toPurge, w.InstanceID) + } + } + + case opts.All: + for _, w := range list { + toPurge = append(toPurge, w.InstanceID) + } + } + } + + wf := workflow.NewClient(cli.Dapr.GrpcClientConn()) + + etcdClient, cancel, err := scheduler.EtcdClient(opts.KubernetesMode, opts.SchedulerNamespace) + if err != nil { + return err + } + defer cancel() + + print.InfoStatusEvent(os.Stdout, "Purging %d workflow instance(s)", len(toPurge)) + + for _, id := range toPurge { + if err = wf.PurgeWorkflowState(ctx, id); err != nil { + return fmt.Errorf("%s: %w", id, err) + } + + paths := []string{ + fmt.Sprintf("dapr/jobs/actorreminder||%s||dapr.internal.%s.%s.workflow||%s||", opts.Namespace, opts.Namespace, opts.AppID, id), + fmt.Sprintf("dapr/jobs/actorreminder||%s||dapr.internal.%s.%s.activity||%s::", opts.Namespace, opts.Namespace, opts.AppID, id), + fmt.Sprintf("dapr/counters/actorreminder||%s||dapr.internal.%s.%s.workflow||%s||", opts.Namespace, opts.Namespace, opts.AppID, id), + fmt.Sprintf("dapr/counters/actorreminder||%s||dapr.internal.%s.%s.activity||%s::", opts.Namespace, opts.Namespace, opts.AppID, id), + } + + oopts := make([]clientv3.Op, 0, len(paths)) + for _, path := range paths { + oopts = append(oopts, clientv3.OpDelete(path, + clientv3.WithPrefix(), + clientv3.WithPrevKV(), + clientv3.WithKeysOnly(), + )) + } + + if _, err = etcdClient.Txn(ctx).Then(oopts...).Commit(); err != nil { + return err + } + + print.SuccessStatusEvent(os.Stdout, "Purged workflow instance %q", id) + } + + return nil +} diff --git a/pkg/workflow/rerun.go b/pkg/workflow/rerun.go new file mode 100644 index 000000000..ccc57ad1f --- /dev/null +++ b/pkg/workflow/rerun.go @@ -0,0 +1,51 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "context" + + "github.com/dapr/cli/pkg/workflow/dclient" + "github.com/dapr/durabletask-go/workflow" +) + +type ReRunOptions struct { + KubernetesMode bool + Namespace string + AppID string + InstanceID string + EventID uint32 + NewInstanceID *string + Input *string +} + +func ReRun(ctx context.Context, opts ReRunOptions) (string, error) { + cli, err := dclient.DaprClient(ctx, opts.KubernetesMode, opts.Namespace, opts.AppID) + if err != nil { + return "", err + } + defer cli.Cancel() + + wf := workflow.NewClient(cli.Dapr.GrpcClientConn()) + + var wopts []workflow.RerunOptions + if opts.NewInstanceID != nil { + wopts = append(wopts, workflow.WithRerunNewInstanceID(*opts.NewInstanceID)) + } + if opts.Input != nil { + wopts = append(wopts, workflow.WithRerunInput(*opts.Input)) + } + + return wf.RerunWorkflowFromEvent(ctx, opts.InstanceID, opts.EventID, wopts...) +} diff --git a/pkg/workflow/run.go b/pkg/workflow/run.go new file mode 100644 index 000000000..4f8a66a89 --- /dev/null +++ b/pkg/workflow/run.go @@ -0,0 +1,55 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "context" + "time" + + "github.com/dapr/cli/pkg/workflow/dclient" + "github.com/dapr/durabletask-go/workflow" +) + +type RunOptions struct { + KubernetesMode bool + Namespace string + AppID string + Name string + InstanceID *string + Input *string + StartTime *time.Time +} + +func Run(ctx context.Context, opts RunOptions) (string, error) { + cli, err := dclient.DaprClient(ctx, opts.KubernetesMode, opts.Namespace, opts.AppID) + if err != nil { + return "", err + } + defer cli.Cancel() + + wf := workflow.NewClient(cli.Dapr.GrpcClientConn()) + + var wopts []workflow.NewWorkflowOptions + if opts.InstanceID != nil { + wopts = append(wopts, workflow.WithInstanceID(*opts.InstanceID)) + } + if opts.Input != nil { + wopts = append(wopts, workflow.WithInput(*opts.Input)) + } + if opts.StartTime != nil { + wopts = append(wopts, workflow.WithStartTime(*opts.StartTime)) + } + + return wf.ScheduleWorkflow(ctx, opts.Name, wopts...) +} diff --git a/pkg/workflow/workflow.go b/pkg/workflow/workflow.go new file mode 100644 index 000000000..033b89d58 --- /dev/null +++ b/pkg/workflow/workflow.go @@ -0,0 +1,85 @@ +/* +Copyright 2025 The Dapr 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 workflow + +import ( + "context" + "fmt" + + "github.com/dapr/cli/pkg/workflow/db" + "github.com/dapr/cli/pkg/workflow/dclient" +) + +type DBOptions struct { + Namespace string + AppID string + Driver string + ConnectionString *string + TableName *string +} + +func metakeys(ctx context.Context, opts DBOptions) ([]string, error) { + if opts.ConnectionString == nil { + return nil, fmt.Errorf("connection string is required for all drivers") + } + + switch { + case dclient.IsSQLDriver(opts.Driver): + tableName := "state" + if opts.TableName != nil { + tableName = *opts.TableName + } + + sqldb, err := db.SQL(ctx, opts.Driver, *opts.ConnectionString) + if err != nil { + return nil, err + } + defer sqldb.Close() + + return db.ListSQL(ctx, sqldb, tableName, db.ListOptions{ + Namespace: opts.Namespace, + AppID: opts.AppID, + }) + + case opts.Driver == "redis": + client, err := db.Redis(ctx, *opts.ConnectionString) + if err != nil { + return nil, err + } + + return db.ListRedis(ctx, client, db.ListOptions{ + Namespace: opts.Namespace, + AppID: opts.AppID, + }) + + case opts.Driver == "mongodb": + client, err := db.Mongo(ctx, *opts.ConnectionString) + if err != nil { + return nil, err + } + + collectionName := "daprCollection" + if opts.TableName != nil { + collectionName = *opts.TableName + } + + return db.ListMongo(ctx, client.Database("daprStore"), collectionName, db.ListOptions{ + Namespace: opts.Namespace, + AppID: opts.AppID, + }) + + default: + return nil, fmt.Errorf("unsupported driver: %s", opts.Driver) + } +} diff --git a/tests/apps/workflow/app.go b/tests/apps/workflow/app.go new file mode 100644 index 000000000..c5d7e335f --- /dev/null +++ b/tests/apps/workflow/app.go @@ -0,0 +1,370 @@ +/* +Copyright 2025 The Dapr 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 main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/dapr/durabletask-go/workflow" + "github.com/dapr/go-sdk/client" + "github.com/dapr/kit/signals" +) + +func main() { + ctx := signals.Context() + register(ctx) + + log.Println("Workflow worker started and ready to accept workflow requests") + + <-ctx.Done() +} + +func register(ctx context.Context) { + r := workflow.NewRegistry() + + workflows := []workflow.Workflow{ + WNoOp, + WTimer, + WActivity1, + SimpleWorkflow, + EventWorkflow, + LongWorkflow, + ChildWorkflow, + ParentWorkflow, + NestedParentWorkflow, + RecursiveChildWorkflow, + FanOutWorkflow, + DataWorkflow, + } + activities := []workflow.Activity{ + ANoOP, + SimpleActivity, + LongRunningActivity, + DataProcessingActivity, + } + + for _, w := range workflows { + if err := r.AddWorkflow(w); err != nil { + log.Fatalf("error adding workflow %T: %v", w, err) + } + } + + for _, a := range activities { + if err := r.AddActivity(a); err != nil { + log.Fatalf("error adding activity %T: %v", a, err) + } + } + + wf, err := client.NewWorkflowClient() + if err != nil { + log.Fatal(err) + } + + if err = wf.StartWorker(ctx, r); err != nil { + log.Fatal(err) + } +} + +func WNoOp(ctx *workflow.WorkflowContext) (any, error) { + return nil, nil +} + +func WTimer(ctx *workflow.WorkflowContext) (any, error) { + return nil, ctx.CreateTimer(time.Hour * 10).Await(nil) +} + +func WActivity1(ctx *workflow.WorkflowContext) (any, error) { + return nil, ctx.CallActivity(ANoOP).Await(nil) +} + +func ANoOP(ctx workflow.ActivityContext) (any, error) { + return nil, nil +} + +func SimpleWorkflow(ctx *workflow.WorkflowContext) (any, error) { + var input any + ctx.GetInput(&input) + + var result string + err := ctx.CallActivity(SimpleActivity, workflow.WithActivityInput(input)).Await(&result) + if err != nil { + return nil, fmt.Errorf("activity failed: %w", err) + } + + ctx.CreateTimer(time.Second * 2).Await(nil) + + return map[string]interface{}{ + "status": "completed", + "result": result, + }, nil +} + +func LongWorkflow(ctx *workflow.WorkflowContext) (any, error) { + stages := []string{"initialization", "processing", "validation", "finalization"} + results := make([]string, 0, len(stages)) + + for _, stage := range stages { + var stageResult string + err := ctx.CallActivity(LongRunningActivity, workflow.WithActivityInput(stage)).Await(&stageResult) + if err != nil { + return nil, fmt.Errorf("stage %s failed: %w", stage, err) + } + results = append(results, stageResult) + + ctx.CreateTimer(time.Second * 1).Await(nil) + } + + return map[string]interface{}{ + "status": "completed", + "stages": stages, + "results": results, + }, nil +} + +func EventWorkflow(ctx *workflow.WorkflowContext) (any, error) { + return nil, ctx.WaitForExternalEvent("test-event", time.Hour).Await(nil) +} + +func DataWorkflow(ctx *workflow.WorkflowContext) (any, error) { + var input struct { + Name string `json:"name"` + Value int `json:"value"` + Data map[string]interface{} `json:"data"` + } + + if err := ctx.GetInput(&input); err != nil { + return nil, fmt.Errorf("failed to get input: %w", err) + } + + var processedData any + err := ctx.CallActivity(DataProcessingActivity, workflow.WithActivityInput(input)).Await(&processedData) + if err != nil { + return nil, fmt.Errorf("data processing failed: %w", err) + } + + output := map[string]interface{}{ + "originalName": input.Name, + "processedName": fmt.Sprintf("processed_%s", input.Name), + "originalValue": input.Value, + "doubledValue": input.Value * 2, + "processedData": processedData, + } + + return output, nil +} + +func SimpleActivity(ctx workflow.ActivityContext) (any, error) { + var input map[string]interface{} + if err := ctx.GetInput(&input); err != nil { + input = make(map[string]interface{}) + } + + time.Sleep(time.Millisecond * 500) + + return fmt.Sprintf("Processed simple activity with input: %v", input), nil +} + +func LongRunningActivity(ctx workflow.ActivityContext) (any, error) { + var stage string + if err := ctx.GetInput(&stage); err != nil { + stage = "unknown" + } + + time.Sleep(time.Second * 2) + + return fmt.Sprintf("Completed %s at %s", stage, time.Now().Format(time.RFC3339)), nil +} + +func DataProcessingActivity(ctx workflow.ActivityContext) (any, error) { + var input map[string]interface{} + if err := ctx.GetInput(&input); err != nil { + return nil, fmt.Errorf("failed to get input: %w", err) + } + + processed := make(map[string]interface{}) + for k, v := range input { + processed[fmt.Sprintf("processed_%s", k)] = v + } + + processed["processedAt"] = time.Now().Format(time.RFC3339) + + return processed, nil +} + +func ParentWorkflow(ctx *workflow.WorkflowContext) (any, error) { + var input map[string]interface{} + if err := ctx.GetInput(&input); err != nil { + input = make(map[string]interface{}) + } + + childInput1 := map[string]interface{}{ + "parentID": ctx.ID(), + "step": 1, + "data": input, + } + var childResult1 map[string]interface{} + if err := ctx.CallChildWorkflow(ChildWorkflow, workflow.WithChildWorkflowInput(childInput1)).Await(&childResult1); err != nil { + return nil, fmt.Errorf("first child workflow failed: %w", err) + } + + childInput2 := map[string]interface{}{ + "parentID": ctx.ID(), + "step": 2, + "previousData": childResult1, + } + var childResult2 map[string]interface{} + if err := ctx.CallChildWorkflow(ChildWorkflow, workflow.WithChildWorkflowInput(childInput2)).Await(&childResult2); err != nil { + return nil, fmt.Errorf("second child workflow failed: %w", err) + } + + return map[string]interface{}{ + "status": "completed", + "parentID": ctx.ID(), + "childResult1": childResult1, + "childResult2": childResult2, + }, nil +} + +func ChildWorkflow(ctx *workflow.WorkflowContext) (any, error) { + var input map[string]interface{} + if err := ctx.GetInput(&input); err != nil { + return nil, fmt.Errorf("failed to get input: %w", err) + } + + ctx.CreateTimer(time.Second).Await(nil) + + var activityResult string + if err := ctx.CallActivity(SimpleActivity, workflow.WithActivityInput(input)).Await(&activityResult); err != nil { + return nil, fmt.Errorf("child activity failed: %w", err) + } + + return map[string]interface{}{ + "childID": ctx.ID(), + "parentID": input["parentID"], + "step": input["step"], + "processed": true, + "activityResult": activityResult, + }, nil +} + +func NestedParentWorkflow(ctx *workflow.WorkflowContext) (any, error) { + + nestedInput := map[string]interface{}{ + "level": 1, + "maxLevel": 3, + "rootID": ctx.ID(), + } + + var nestedResult map[string]interface{} + if err := ctx.CallChildWorkflow(RecursiveChildWorkflow, workflow.WithChildWorkflowInput(nestedInput)).Await(&nestedResult); err != nil { + return nil, fmt.Errorf("nested child workflow failed: %w", err) + } + + return map[string]interface{}{ + "status": "completed", + "rootID": ctx.ID(), + "nestedResult": nestedResult, + }, nil +} + +func RecursiveChildWorkflow(ctx *workflow.WorkflowContext) (any, error) { + var input struct { + Level int `json:"level"` + MaxLevel int `json:"maxLevel"` + RootID string `json:"rootID"` + Data map[string]interface{} `json:"data"` + } + + if err := ctx.GetInput(&input); err != nil { + return nil, fmt.Errorf("failed to get input: %w", err) + } + + result := map[string]interface{}{ + "instanceID": ctx.ID(), + "level": input.Level, + "rootID": input.RootID, + } + + if input.Level < input.MaxLevel { + childInput := map[string]interface{}{ + "level": input.Level + 1, + "maxLevel": input.MaxLevel, + "rootID": input.RootID, + "data": input.Data, + } + + var childResult map[string]interface{} + if err := ctx.CallChildWorkflow(RecursiveChildWorkflow, workflow.WithChildWorkflowInput(childInput)).Await(&childResult); err != nil { + return nil, fmt.Errorf("recursive child at level %d failed: %w", input.Level+1, err) + } + result["childResult"] = childResult + } else { + var activityResult string + if err := ctx.CallActivity(SimpleActivity, workflow.WithActivityInput(input.Data)).Await(&activityResult); err != nil { + return nil, fmt.Errorf("activity at max level failed: %w", err) + } + result["finalActivity"] = activityResult + } + + return result, nil +} + +func FanOutWorkflow(ctx *workflow.WorkflowContext) (any, error) { + var input struct { + ParallelCount int `json:"parallelCount"` + Data map[string]interface{} `json:"data"` + } + + input.ParallelCount = 3 + ctx.GetInput(&input) + + if input.ParallelCount <= 0 { + input.ParallelCount = 3 + } + if input.ParallelCount > 10 { + } + + var childTasks []workflow.Task + for i := 0; i < input.ParallelCount; i++ { + childInput := map[string]interface{}{ + "parentID": ctx.ID(), + "index": i, + "data": input.Data, + } + task := ctx.CallChildWorkflow(ChildWorkflow, workflow.WithChildWorkflowInput(childInput)) + childTasks = append(childTasks, task) + } + + results := make([]map[string]interface{}, 0, len(childTasks)) + for i, task := range childTasks { + var result map[string]interface{} + if err := task.Await(&result); err != nil { + result = map[string]interface{}{ + "index": i, + "error": err.Error(), + } + } + results = append(results, result) + } + + return map[string]interface{}{ + "status": "completed", + "parentID": ctx.ID(), + "parallelCount": input.ParallelCount, + "results": results, + }, nil +} diff --git a/tests/apps/workflow/go.mod b/tests/apps/workflow/go.mod new file mode 100644 index 000000000..9beb717cc --- /dev/null +++ b/tests/apps/workflow/go.mod @@ -0,0 +1,29 @@ +module workflow + +go 1.24.7 + +require ( + github.com/dapr/durabletask-go v0.10.0 + github.com/dapr/go-sdk v1.13.0 + github.com/dapr/kit v0.16.1 +) + +require ( + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/dapr/dapr v1.16.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.73.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tests/apps/workflow/go.sum b/tests/apps/workflow/go.sum new file mode 100644 index 000000000..deeb81574 --- /dev/null +++ b/tests/apps/workflow/go.sum @@ -0,0 +1,71 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/dapr/dapr v1.16.0 h1:la2WLZM8Myr2Pq3cyrFjHKWDSPYLzGZCs3p502TwBjI= +github.com/dapr/dapr v1.16.0/go.mod h1:ln/mxvNOeqklaDmic4ppsxmnjl2D/oZGKaJy24IwaEY= +github.com/dapr/durabletask-go v0.10.0 h1:vfIivPl4JYd55xZTslDwhA6p6F8ipcNxBtMaupxArr8= +github.com/dapr/durabletask-go v0.10.0/go.mod h1:0Ts4rXp74JyG19gDWPcwNo5V6NBZzhARzHF5XynmA7Q= +github.com/dapr/go-sdk v1.13.0 h1:Qw2BmUonClQ9yK/rrEEaFL1PyDgq616RrvYj0CT67Lk= +github.com/dapr/go-sdk v1.13.0/go.mod h1:RsffVNZitDApmQqoS68tNKGMXDZUjTviAbKZupJSzts= +github.com/dapr/kit v0.16.1 h1:MqLAhHVg8trPy2WJChMZFU7ToeondvxcNHYVvMDiVf4= +github.com/dapr/kit v0.16.1/go.mod h1:40ZWs5P6xfYf7O59XgwqZkIyDldTIXlhTQhGop8QoSM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +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= diff --git a/tests/e2e/standalone/commands.go b/tests/e2e/standalone/commands.go index fa1530ed8..c3e578ea4 100644 --- a/tests/e2e/standalone/commands.go +++ b/tests/e2e/standalone/commands.go @@ -267,3 +267,57 @@ func cmdSchedulerImport(args ...string) (string, error) { return spawn.Command(common.GetDaprPath(), importArgs...) } + +func cmdWorkflowList(appID string, args ...string) (string, error) { + allArgs := []string{"workflow", "list", "-a", appID} + allArgs = append(allArgs, args...) + return spawn.Command(common.GetDaprPath(), allArgs...) +} + +func cmdWorkflowRun(appID, workflowName string, args ...string) (string, error) { + allArgs := []string{"workflow", "run", "-a", appID, workflowName} + allArgs = append(allArgs, args...) + return spawn.Command(common.GetDaprPath(), allArgs...) +} + +func cmdWorkflowHistory(appID, instanceID string, args ...string) (string, error) { + allArgs := []string{"workflow", "history", "-a", appID, instanceID} + allArgs = append(allArgs, args...) + return spawn.Command(common.GetDaprPath(), allArgs...) +} + +func cmdWorkflowSuspend(appID, instanceID string, args ...string) (string, error) { + allArgs := []string{"workflow", "suspend", "-a", appID, instanceID} + allArgs = append(allArgs, args...) + return spawn.Command(common.GetDaprPath(), allArgs...) +} + +func cmdWorkflowResume(appID, instanceID string, args ...string) (string, error) { + allArgs := []string{"workflow", "resume", "-a", appID, instanceID} + allArgs = append(allArgs, args...) + return spawn.Command(common.GetDaprPath(), allArgs...) +} + +func cmdWorkflowTerminate(appID, instanceID string, args ...string) (string, error) { + allArgs := []string{"workflow", "terminate", "-a", appID, instanceID} + allArgs = append(allArgs, args...) + return spawn.Command(common.GetDaprPath(), allArgs...) +} + +func cmdWorkflowRaiseEvent(appID, eventArg string, args ...string) (string, error) { + allArgs := []string{"workflow", "raise-event", "-a", appID, eventArg} + allArgs = append(allArgs, args...) + return spawn.Command(common.GetDaprPath(), allArgs...) +} + +func cmdWorkflowReRun(appID, instanceID string, args ...string) (string, error) { + allArgs := []string{"workflow", "rerun", "-a", appID, instanceID} + allArgs = append(allArgs, args...) + return spawn.Command(common.GetDaprPath(), allArgs...) +} + +func cmdWorkflowPurge(appID string, args ...string) (string, error) { + allArgs := []string{"workflow", "purge", "-a", appID} + allArgs = append(allArgs, args...) + return spawn.Command(common.GetDaprPath(), allArgs...) +} diff --git a/tests/e2e/standalone/scheduler_test.go b/tests/e2e/standalone/scheduler_test.go index 5549aba5c..b95d6c086 100644 --- a/tests/e2e/standalone/scheduler_test.go +++ b/tests/e2e/standalone/scheduler_test.go @@ -61,6 +61,8 @@ func TestSchedulerList(t *testing.T) { assert.Len(c, strings.Split(output, "\n"), 10) }, time.Second*30, time.Millisecond*10) + time.Sleep(time.Second * 3) + t.Run("short", func(t *testing.T) { output, err := cmdSchedulerList() require.NoError(t, err) @@ -89,8 +91,6 @@ func TestSchedulerList(t *testing.T) { count, err := strconv.Atoi(strings.Fields(line)[2]) require.NoError(t, err) assert.Equal(t, 1, count) - - assert.NotEmpty(t, strings.Fields(line)[3]) } expNames = []string{ @@ -105,6 +105,9 @@ func TestSchedulerList(t *testing.T) { count, err := strconv.Atoi(strings.Fields(line)[2]) require.NoError(t, err) assert.Equal(t, 0, count) + if err != nil { + return + } } expNames = []string{ @@ -229,13 +232,21 @@ func TestSchedulerGet(t *testing.T) { lines := strings.Split(output, "\n") require.Len(t, lines, 3) - require.Equal(t, []string{ - "NAME", - "BEGIN", - "COUNT", - "LAST", - "TRIGGER", - }, strings.Fields(lines[0])) + if strings.HasPrefix(name, "activity/") { + require.Equal(t, []string{ + "NAME", + "BEGIN", + "COUNT", + }, strings.Fields(lines[0]), name) + } else { + require.Equal(t, []string{ + "NAME", + "BEGIN", + "COUNT", + "LAST", + "TRIGGER", + }, strings.Fields(lines[0]), name) + } } }) @@ -246,20 +257,47 @@ func TestSchedulerGet(t *testing.T) { lines := strings.Split(output, "\n") require.Len(t, lines, 3) - require.Equal(t, []string{ - "NAMESPACE", - "NAME", - "BEGIN", - "EXPIRATION", - "SCHEDULE", - "DUE", - "TIME", - "TTL", - "REPEATS", - "COUNT", - "LAST", - "TRIGGER", - }, strings.Fields(lines[0])) + switch { + case name == "app/test-scheduler/test2": + require.Equal(t, []string{ + "NAMESPACE", + "NAME", + "BEGIN", + "EXPIRATION", + "SCHEDULE", + "DUE", + "TIME", + "TTL", + "REPEATS", + "COUNT", + "LAST", + "TRIGGER", + }, strings.Fields(lines[0]), name) + + case strings.HasPrefix(name, "activity/"): + require.Equal(t, []string{ + "NAMESPACE", + "NAME", + "BEGIN", + "DUE", + "TIME", + "COUNT", + }, strings.Fields(lines[0]), name) + + default: + require.Equal(t, []string{ + "NAMESPACE", + "NAME", + "BEGIN", + "SCHEDULE", + "DUE", + "TIME", + "REPEATS", + "COUNT", + "LAST", + "TRIGGER", + }, strings.Fields(lines[0]), name) + } } }) diff --git a/tests/e2e/standalone/workflow_test.go b/tests/e2e/standalone/workflow_test.go new file mode 100644 index 000000000..471a18a60 --- /dev/null +++ b/tests/e2e/standalone/workflow_test.go @@ -0,0 +1,731 @@ +//go:build !windows && (e2e || template) + +/* +Copyright 2025 The Dapr 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 standalone_test + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + redisConnString = "--connection-string=redis://127.0.0.1:6379" +) + +func TestWorkflowList(t *testing.T) { + if isSlimMode() { + t.Skip("skipping workflow tests in slim mode") + } + + cmdUninstall() + ensureDaprInstallation(t) + t.Cleanup(func() { + must(t, cmdUninstall, "failed to uninstall Dapr") + }) + + runFilePath := "../testdata/run-template-files/test-workflow.yaml" + appID := "test-workflow" + t.Cleanup(func() { + cmdStopWithAppID(appID) + waitAppsToBeStopped() + }) + args := []string{"-f", runFilePath} + + go func() { + o, _ := cmdRun("", args...) + t.Log(o) + }() + + time.Sleep(time.Second * 5) + output, err := cmdWorkflowList(appID, redisConnString) + require.NoError(t, err) + assert.Equal(t, `❌ No workflow found in namespace "default" for app ID "test-workflow" +`, output) + + _, err = cmdWorkflowRun(appID, "LongWorkflow", "--instance-id=foo") + require.NoError(t, err, output) + + t.Run("terminate workflow", func(t *testing.T) { + output, err := cmdWorkflowTerminate(appID, "foo") + require.NoError(t, err) + assert.Contains(t, output, "terminated successfully") + }) + + t.Run("verify terminated state", func(t *testing.T) { + output, err := cmdWorkflowList(appID, redisConnString, "-o", "json") + require.NoError(t, err, output) + + var list []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(output), &list)) + + found := false + for _, item := range list { + if item["instanceID"] == "foo" { + assert.Equal(t, "TERMINATED", item["runtimeStatus"]) + found = true + break + } + } + assert.True(t, found, "Workflow instance not found") + }) +} + +func TestWorkflowRaiseEvent(t *testing.T) { + if isSlimMode() { + t.Skip("skipping workflow tests in slim mode") + } + + cmdUninstall() + ensureDaprInstallation(t) + t.Cleanup(func() { + must(t, cmdUninstall, "failed to uninstall Dapr") + }) + + runFilePath := "../testdata/run-template-files/test-workflow.yaml" + appID := "test-workflow" + t.Cleanup(func() { + cmdStopWithAppID(appID) + waitAppsToBeStopped() + }) + args := []string{"-f", runFilePath} + + go func() { + o, _ := cmdRun("", args...) + t.Log(o) + }() + + time.Sleep(time.Second * 5) + output, err := cmdWorkflowRun(appID, "EventWorkflow", "--instance-id=foo") + require.NoError(t, err, output) + + t.Run("raise event", func(t *testing.T) { + output, err := cmdWorkflowRaiseEvent(appID, "foo/test-event") + require.NoError(t, err) + assert.Contains(t, output, "raised event") + assert.Contains(t, output, "successfully") + + time.Sleep(time.Second) + + output, err = cmdWorkflowList(appID, redisConnString, "-o", "json") + require.NoError(t, err, output) + + var list []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(output), &list)) + + found := false + for _, item := range list { + if item["instanceID"] == "foo" { + assert.Equal(t, "COMPLETED", item["runtimeStatus"]) + found = true + break + } + } + assert.True(t, found, "Workflow instance not found") + }) + + t.Run("raise event with input", func(t *testing.T) { + output, err := cmdWorkflowRun(appID, "EventWorkflow", "--instance-id=bar") + require.NoError(t, err) + + input := `{"eventData": "test data", "value": 456}` + output, err = cmdWorkflowRaiseEvent(appID, "bar/test-event", "--input", input) + require.NoError(t, err) + assert.Contains(t, output, "raised event") + + time.Sleep(time.Second) + + output, err = cmdWorkflowList(appID, redisConnString, "-o", "json") + require.NoError(t, err, output) + + var list []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(output), &list)) + + found := false + for _, item := range list { + if item["instanceID"] == "foo" { + assert.Equal(t, "COMPLETED", item["runtimeStatus"]) + found = true + break + } + } + assert.True(t, found, "Workflow instance not found") + }) +} + +func TestWorkflowReRun(t *testing.T) { + if isSlimMode() { + t.Skip("skipping workflow tests in slim mode") + } + + cmdUninstall() + ensureDaprInstallation(t) + t.Cleanup(func() { + must(t, cmdUninstall, "failed to uninstall Dapr") + }) + + runFilePath := "../testdata/run-template-files/test-workflow.yaml" + appID := "test-workflow" + t.Cleanup(func() { + cmdStopWithAppID(appID) + waitAppsToBeStopped() + }) + args := []string{"-f", runFilePath} + + go func() { + o, _ := cmdRun("", args...) + t.Log(o) + }() + + time.Sleep(time.Second * 5) + + output, err := cmdWorkflowRun(appID, "SimpleWorkflow", "--instance-id=foo") + require.NoError(t, err, output) + + time.Sleep(3 * time.Second) + + t.Run("rerun from beginning", func(t *testing.T) { + output, err := cmdWorkflowReRun(appID, "foo") + require.NoError(t, err) + assert.Contains(t, output, "Rerunning workflow instance") + }) + + t.Run("rerun with new instance ID", func(t *testing.T) { + output, err := cmdWorkflowReRun(appID, "foo", "--new-instance-id", "bar") + require.NoError(t, err) + assert.Contains(t, output, "bar") + }) + + t.Run("rerun from specific event", func(t *testing.T) { + output, err := cmdWorkflowReRun(appID, "foo", "-e", "1") + require.NoError(t, err) + assert.Contains(t, output, "Rerunning workflow instance") + }) + + t.Run("rerun with new input", func(t *testing.T) { + input := `{"rerun": true, "data": "new input"}` + output, err := cmdWorkflowReRun(appID, "foo", "--input", input) + require.NoError(t, err) + assert.Contains(t, output, "Rerunning workflow instance") + }) +} + +func TestWorkflowPurge(t *testing.T) { + if isSlimMode() { + t.Skip("skipping workflow tests in slim mode") + } + + cmdUninstall() + ensureDaprInstallation(t) + t.Cleanup(func() { + must(t, cmdUninstall, "failed to uninstall Dapr") + }) + + runFilePath := "../testdata/run-template-files/test-workflow.yaml" + appID := "test-workflow" + t.Cleanup(func() { + cmdStopWithAppID(appID) + waitAppsToBeStopped() + }) + args := []string{"-f", runFilePath} + + go func() { + o, _ := cmdRun("", args...) + t.Log(o) + }() + + time.Sleep(5 * time.Second) + + for i := 0; i < 3; i++ { + output, err := cmdWorkflowRun(appID, "SimpleWorkflow", + "--instance-id=purge-test-"+strconv.Itoa(i)) + require.NoError(t, err, output) + } + + time.Sleep(5 * time.Second) + + _, err := cmdWorkflowTerminate(appID, "purge-test-0") + require.NoError(t, err) + + t.Run("purge single instance", func(t *testing.T) { + output, err := cmdWorkflowPurge(appID, "purge-test-0") + require.NoError(t, err) + assert.Contains(t, output, "Purged") + + output, err = cmdWorkflowList(appID, "-o", "json", redisConnString) + require.NoError(t, err) + assert.NotContains(t, output, "purge-test-0") + }) + + t.Run("purge multiple instances", func(t *testing.T) { + _, _ = cmdWorkflowTerminate(appID, "purge-test-1") + _, _ = cmdWorkflowTerminate(appID, "purge-test-2") + time.Sleep(1 * time.Second) + + output, err := cmdWorkflowPurge(appID, "purge-test-1", "purge-test-2") + require.NoError(t, err) + assert.Contains(t, output, "Purged") + }) + + t.Run("purge all terminal", func(t *testing.T) { + for i := 0; i < 2; i++ { + output, err := cmdWorkflowRun(appID, "SimpleWorkflow", + "--instance-id=purge-all-"+strconv.Itoa(i)) + require.NoError(t, err, output) + _, _ = cmdWorkflowTerminate(appID, "purge-all-"+strconv.Itoa(i)) + } + + output, err := cmdWorkflowPurge(appID, redisConnString, "--all") + require.NoError(t, err, output) + assert.Contains(t, output, `Purged workflow instance "purge-all-1"`) + assert.Contains(t, output, `Purged workflow instance "purge-all-0"`) + + output, err = cmdWorkflowList(appID, "-o", "json", redisConnString) + require.NoError(t, err) + assert.NotContains(t, output, "purge-all-0") + assert.NotContains(t, output, "purge-all-1") + }) + + t.Run("purge older than duration", func(t *testing.T) { + output, err := cmdWorkflowRun(appID, "SimpleWorkflow", + "--instance-id=purge-older") + require.NoError(t, err) + + time.Sleep(5 * time.Second) + + output, err = cmdWorkflowPurge(appID, redisConnString, "--all-older-than", "1s") + require.NoError(t, err, output) + assert.Contains(t, output, "Purging 1 workflow instance(s)") + assert.Contains(t, output, `Purged workflow instance "purge-older"`) + + output, err = cmdWorkflowList(appID, "-o", "json", redisConnString) + require.NoError(t, err, output) + assert.NotContains(t, output, "purge-older") + }) + + t.Run("also purge scheduler", func(t *testing.T) { + output, err := cmdWorkflowRun(appID, "EventWorkflow", + "--instance-id=also-sched") + require.NoError(t, err) + + output, err = cmdWorkflowTerminate(appID, "also-sched") + require.NoError(t, err, output) + + output, err = cmdSchedulerList() + require.NoError(t, err) + assert.Greater(t, len(strings.Split(output, "\n")), 2) + + output, err = cmdWorkflowPurge(appID, "also-sched") + require.NoError(t, err, output) + + output, err = cmdSchedulerList() + require.NoError(t, err) + assert.Len(t, strings.Split(output, "\n"), 2) + }) +} + +func TestWorkflowFilters(t *testing.T) { + if isSlimMode() { + t.Skip("skipping workflow tests in slim mode") + } + + cmdUninstall() + ensureDaprInstallation(t) + t.Cleanup(func() { + must(t, cmdUninstall, "failed to uninstall Dapr") + }) + + runFilePath := "../testdata/run-template-files/test-workflow.yaml" + appID := "test-workflow" + t.Cleanup(func() { + cmdStopWithAppID(appID) + waitAppsToBeStopped() + }) + args := []string{"-f", runFilePath} + + go func() { + o, _ := cmdRun("", args...) + t.Log(o) + }() + + time.Sleep(5 * time.Second) + + _, _ = cmdWorkflowRun(appID, "SimpleWorkflow", "--instance-id=simple-1") + _, _ = cmdWorkflowRun(appID, "LongWorkflow", "--instance-id=long-1") + output, err := cmdWorkflowRun(appID, "EventWorkflow", "--instance-id=suspend-test") + require.NoError(t, err, output) + + time.Sleep(2 * time.Second) + _, _ = cmdWorkflowSuspend(appID, "suspend-test") + + t.Run("filter by status", func(t *testing.T) { + output, err := cmdWorkflowList(appID, redisConnString, "--filter-status", "SUSPENDED") + require.NoError(t, err) + assert.Contains(t, output, "suspend-test") + assert.NotContains(t, output, "simple-1") + assert.NotContains(t, output, "long-1") + }) + + t.Run("filter by name", func(t *testing.T) { + output, err := cmdWorkflowList(appID, redisConnString, "--filter-name", "SimpleWorkflow") + require.NoError(t, err) + lines := strings.Split(output, "\n") + + for i, line := range lines { + if i == 0 || strings.TrimSpace(line) == "" { + continue + } + assert.Contains(t, line, "SimpleWorkflow") + } + }) + + t.Run("filter by max age", func(t *testing.T) { + output, err := cmdWorkflowList(appID, redisConnString, "--filter-max-age", "10s") + require.NoError(t, err) + assert.NotEmpty(t, output) + + output, err = cmdWorkflowList(appID, redisConnString, "--filter-max-age", "0s") + require.NoError(t, err) + lines := strings.Split(output, "\n") + assert.LessOrEqual(t, len(lines), 2) + }) +} + +func TestWorkflowChildCalls(t *testing.T) { + if isSlimMode() { + t.Skip("skipping workflow tests in slim mode") + } + + cmdUninstall() + ensureDaprInstallation(t) + t.Cleanup(func() { + must(t, cmdUninstall, "failed to uninstall Dapr") + }) + + runFilePath := "../testdata/run-template-files/test-workflow.yaml" + appID := "test-workflow" + t.Cleanup(func() { + cmdStopWithAppID(appID) + waitAppsToBeStopped() + }) + args := []string{"-f", runFilePath} + + go func() { + o, _ := cmdRun("", args...) + t.Log(o) + }() + + time.Sleep(5 * time.Second) + + t.Run("parent child workflow", func(t *testing.T) { + input := `{"test": "parent-child", "value": 42}` + output, err := cmdWorkflowRun(appID, "ParentWorkflow", "--input", input, "--instance-id=parent-1") + require.NoError(t, err, output) + + time.Sleep(5 * time.Second) + + output, err = cmdWorkflowList(appID, redisConnString, "-o", "json") + require.NoError(t, err) + + var list []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(output), &list)) + + var parentFound bool + var childCount int + for _, item := range list { + if item["instanceID"] == "parent-1" { + parentFound = true + assert.Equal(t, "ParentWorkflow", item["name"]) + } + if name, ok := item["name"].(string); ok && name == "ChildWorkflow" { + childCount++ + } + } + assert.True(t, parentFound, "Parent workflow not found") + assert.GreaterOrEqual(t, childCount, 2, "Expected at least 2 child workflows") + }) + + t.Run("nested child workflows", func(t *testing.T) { + output, err := cmdWorkflowRun(appID, "NestedParentWorkflow", "--instance-id=nested-parent") + require.NoError(t, err) + + time.Sleep(6 * time.Second) + + output, err = cmdWorkflowList(appID, redisConnString, "-o", "json") + require.NoError(t, err) + + var list []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(output), &list)) + + var recursiveCount int + for _, item := range list { + if name, ok := item["name"].(string); ok && name == "RecursiveChildWorkflow" { + recursiveCount++ + } + } + assert.GreaterOrEqual(t, recursiveCount, 2, "Expected multiple recursive child workflows") + }) + + t.Run("fan out workflow", func(t *testing.T) { + parallelCount := 5 + input := fmt.Sprintf(`{"parallelCount": %d, "data": {"test": "fanout"}}`, parallelCount) + output, err := cmdWorkflowRun(appID, "FanOutWorkflow", "--input", input, "--instance-id=fanout-1") + require.NoError(t, err) + + time.Sleep(5 * time.Second) + + output, err = cmdWorkflowList(appID, redisConnString, "-o", "json") + require.NoError(t, err) + + var list []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(output), &list)) + + var fanOutChildren int + for _, item := range list { + if name, ok := item["name"].(string); ok && name == "ChildWorkflow" { + fanOutChildren++ + } + } + assert.GreaterOrEqual(t, fanOutChildren, parallelCount, "Expected at least %d child workflows from fan-out", parallelCount) + }) + + t.Run("child workflow failure handling", func(t *testing.T) { + output, err := cmdWorkflowRun(appID, "ParentWorkflow", "--input", `{"fail": true}`, "--instance-id=parent-1") + require.NoError(t, err, output) + + time.Sleep(5 * time.Second) + + output, err = cmdWorkflowList(appID, redisConnString, "-o", "json") + require.NoError(t, err) + + var list []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(output), &list)) + + for _, item := range list { + if item["instanceID"] == "parent-1" { + status := item["runtimeStatus"].(string) + assert.Contains(t, []string{"COMPLETED", "FAILED"}, status) + break + } + } + }) +} + +func TestWorkflowHistory(t *testing.T) { + if isSlimMode() { + t.Skip("skipping workflow tests in slim mode") + } + + cmdUninstall() + ensureDaprInstallation(t) + t.Cleanup(func() { + must(t, cmdUninstall, "failed to uninstall Dapr") + }) + + runFilePath := "../testdata/run-template-files/test-workflow.yaml" + appID := "test-workflow" + t.Cleanup(func() { + cmdStopWithAppID(appID) + waitAppsToBeStopped() + }) + args := []string{"-f", runFilePath} + + go func() { + o, _ := cmdRun("", args...) + t.Log(o) + }() + + // Wait and create a workflow + time.Sleep(5 * time.Second) + output, err := cmdWorkflowRun(appID, "SimpleWorkflow", "--instance-id=history-test") + require.NoError(t, err, output) + + // Wait for workflow to have some history + time.Sleep(2 * time.Second) + + t.Run("get history", func(t *testing.T) { + output, err := cmdWorkflowHistory(appID, "history-test") + require.NoError(t, err) + lines := strings.Split(output, "\n") + + // Should have headers and at least one history entry + assert.GreaterOrEqual(t, len(lines), 2) + + headers := strings.Fields(lines[0]) + assert.Contains(t, headers, "TYPE") + assert.Contains(t, headers, "ELAPSED") + }) + + t.Run("get history json", func(t *testing.T) { + output, err := cmdWorkflowHistory(appID, "history-test", "-o", "json") + require.NoError(t, err) + + var history []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(output), &history)) + assert.GreaterOrEqual(t, len(history), 1) + }) +} + +func TestWorkflowSuspendResume(t *testing.T) { + if isSlimMode() { + t.Skip("skipping workflow tests in slim mode") + } + + cmdUninstall() + ensureDaprInstallation(t) + t.Cleanup(func() { + must(t, cmdUninstall, "failed to uninstall Dapr") + }) + + runFilePath := "../testdata/run-template-files/test-workflow.yaml" + appID := "test-workflow" + t.Cleanup(func() { + cmdStopWithAppID(appID) + waitAppsToBeStopped() + }) + args := []string{"-f", runFilePath} + + go func() { + o, _ := cmdRun("", args...) + t.Log(o) + }() + + // Wait and create a long-running workflow + time.Sleep(5 * time.Second) + output, err := cmdWorkflowRun(appID, "LongWorkflow", "--instance-id=suspend-resume-test") + require.NoError(t, err, output) + + t.Run("suspend workflow", func(t *testing.T) { + output, err := cmdWorkflowSuspend(appID, "suspend-resume-test") + require.NoError(t, err, output) + assert.Contains(t, output, "Workflow 'suspend-resume-test' suspended successfully") + }) + + t.Run("verify suspended state", func(t *testing.T) { + output, err := cmdWorkflowList(appID, redisConnString, "-o", "json") + require.NoError(t, err, output) + + var list []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(output), &list)) + + found := false + for _, item := range list { + if item["instanceID"] == "suspend-resume-test" { + assert.Equal(t, "SUSPENDED", item["runtimeStatus"]) + found = true + break + } + } + assert.True(t, found, "Workflow instance not found") + }) + + t.Run("resume workflow", func(t *testing.T) { + output, err := cmdWorkflowResume(appID, "suspend-resume-test") + require.NoError(t, err) + assert.Contains(t, output, "Workflow 'suspend-resume-test' resumed successfully") + }) + + t.Run("verify resumed state", func(t *testing.T) { + time.Sleep(1 * time.Second) + output, err := cmdWorkflowList(appID, redisConnString, "-o", "json") + require.NoError(t, err) + + var list []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(output), &list)) + + found := false + for _, item := range list { + if item["instanceID"] == "suspend-resume-test" { + assert.NotEqual(t, "SUSPENDED", item["runtimeStatus"]) + found = true + break + } + } + assert.True(t, found, "Workflow instance not found") + }) +} + +func TestWorkflowTerminate(t *testing.T) { + if isSlimMode() { + t.Skip("skipping workflow tests in slim mode") + } + + cmdUninstall() + ensureDaprInstallation(t) + t.Cleanup(func() { + must(t, cmdUninstall, "failed to uninstall Dapr") + }) + + runFilePath := "../testdata/run-template-files/test-workflow.yaml" + appID := "test-workflow" + t.Cleanup(func() { + cmdStopWithAppID(appID) + waitAppsToBeStopped() + }) + args := []string{"-f", runFilePath} + + go func() { + o, _ := cmdRun("", args...) + t.Log(o) + }() + + // Wait and create a workflow for testing + time.Sleep(5 * time.Second) + output, err := cmdWorkflowRun(appID, "LongWorkflow", "--instance-id=terminate-test") + require.NoError(t, err, output) + + t.Run("terminate workflow", func(t *testing.T) { + output, err := cmdWorkflowTerminate(appID, "terminate-test") + require.NoError(t, err) + assert.Contains(t, output, "terminated successfully") + }) + + t.Run("verify terminated state", func(t *testing.T) { + output, err := cmdWorkflowList(appID, redisConnString, "-o", "json") + require.NoError(t, err) + + var list []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(output), &list)) + + found := false + for _, item := range list { + if item["instanceID"] == "terminate-test" { + assert.Equal(t, "TERMINATED", item["runtimeStatus"]) + found = true + break + } + } + assert.True(t, found, "Workflow instance not found") + }) + + t.Run("terminate with output", func(t *testing.T) { + // Create another workflow + output, err := cmdWorkflowRun(appID, "LongWorkflow", "--instance-id=terminate-output-test") + require.NoError(t, err, output) + + outputData := `{"reason": "test termination", "code": 123}` + output, err = cmdWorkflowTerminate(appID, "terminate-output-test", "-o", outputData) + require.NoError(t, err) + assert.Contains(t, output, "terminated successfully") + }) +} diff --git a/tests/e2e/testdata/run-template-files/test-workflow.yaml b/tests/e2e/testdata/run-template-files/test-workflow.yaml new file mode 100644 index 000000000..ff4ccd3bb --- /dev/null +++ b/tests/e2e/testdata/run-template-files/test-workflow.yaml @@ -0,0 +1,10 @@ +version: 1 +apps: +- appID: test-workflow + appDirPath: ../../../apps/workflow/ + command: ["go", "run", "app.go"] + appLogDestination: console + daprdLogDestination: console + daprGRPCPort: 3510 + schedulerHostAddress: 127.0.0.1:50006 + placementHostAddress: 127.0.0.1:50005 diff --git a/utils/utils.go b/utils/utils.go index 286da4289..343f6f8a0 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -17,6 +17,7 @@ import ( "bufio" "bytes" "context" + "encoding/csv" "encoding/json" "errors" "fmt" @@ -98,33 +99,85 @@ func WriteTable(writer io.Writer, csvContent string) { table := tablewriter.NewWriter(&output) table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) table.SetHeaderLine(false) - table.SetBorders(tablewriter.Border{ - Top: false, - Bottom: false, - }) + table.SetBorders(tablewriter.Border{Top: false, Bottom: false}) table.SetTablePadding("") table.SetRowSeparator("") table.SetColumnSeparator("") table.SetAlignment(tablewriter.ALIGN_LEFT) - scanner := bufio.NewScanner(strings.NewReader(csvContent)) - header := true + table.SetAutoWrapText(false) - for scanner.Scan() { - text := strings.Split(scanner.Text(), ",") + r := csv.NewReader(strings.NewReader(csvContent)) + r.FieldsPerRecord = -1 - if header { - table.SetHeader(text) - header = false - } else { - table.Append(text) + var header []string + var rows [][]string + first := true + + for { + rec, err := r.Read() + if err == io.EOF { + break + } + if err != nil { + continue + } + for i := range rec { + rec[i] = sanitizeCell(rec[i]) + } + + if first { + header = rec + first = false + continue + } + rows = append(rows, rec) + } + + if len(header) == 0 { + return + } + + // Pad rows to header len (so indexing is safe) + for i := range rows { + if len(rows[i]) < len(header) { + pad := make([]string, len(header)-len(rows[i])) + rows[i] = append(rows[i], pad...) + } + } + + var keepIdx []int + for c := range header { + if !allBlank(c, rows) { + keepIdx = append(keepIdx, c) } } + if len(keepIdx) == 0 { + for i := range header { + keepIdx = append(keepIdx, i) + } + } + + filter := func(src []string) []string { + dst := make([]string, len(keepIdx)) + for i, c := range keepIdx { + if c < len(src) { + dst[i] = src[c] + } + } + return dst + } + + table.SetHeader(filter(header)) + for _, rrow := range rows { + table.Append(filter(rrow)) + } + table.Render() - b := bufio.NewScanner(&output) - for b.Scan() { - writer.Write(bytes.TrimLeft(b.Bytes(), " ")) + sc := bufio.NewScanner(&output) + for sc.Scan() { + writer.Write(bytes.TrimLeft(sc.Bytes(), " ")) writer.Write([]byte("\n")) } } @@ -465,3 +518,20 @@ func HumanizeDuration(d time.Duration) string { return fmt.Sprintf("%.1fh", d.Hours()) } } + +func sanitizeCell(s string) string { + s = strings.ReplaceAll(s, "\r\n", " ") + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + s = strings.TrimSpace(strings.Join(strings.Fields(s), " ")) + return s +} + +func allBlank(col int, rows [][]string) bool { + for _, r := range rows { + if col < len(r) && strings.TrimSpace(r[col]) != "" { + return false + } + } + return true +}