diff --git a/cmd/run.go b/cmd/run.go index 6ba6f9e8b..37dad5a3a 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -16,6 +16,7 @@ package cmd import ( "bufio" "fmt" + "io" "os" "runtime" "strconv" @@ -27,6 +28,7 @@ import ( "github.com/dapr/cli/pkg/metadata" "github.com/dapr/cli/pkg/print" + runExec "github.com/dapr/cli/pkg/runexec" "github.com/dapr/cli/pkg/standalone" "github.com/dapr/cli/pkg/standalone/runfileconfig" "github.com/dapr/cli/utils" @@ -97,6 +99,10 @@ dapr run --app-id myapp --dapr-path /usr/local/dapr }, Run: func(cmd *cobra.Command, args []string) { if len(runFilePath) > 0 { + if runtime.GOOS == "windows" { + print.FailureStatusEvent(os.Stderr, "The run command with run file is not supported on Windows") + os.Exit(1) + } runConfigFilePath, err := getRunFilePath(runFilePath) if err != nil { print.FailureStatusEvent(os.Stderr, "Failed to get run file path: %v", err) @@ -159,7 +165,7 @@ dapr run --app-id myapp --dapr-path /usr/local/dapr APIListenAddresses: apiListenAddresses, DaprdInstallPath: daprPath, } - output, err := standalone.Run(&standalone.RunConfig{ + output, err := runExec.NewOutput(&standalone.RunConfig{ AppID: appID, AppPort: appPort, HTTPPort: port, @@ -175,6 +181,7 @@ dapr run --app-id myapp --dapr-path /usr/local/dapr print.FailureStatusEvent(os.Stderr, err.Error()) os.Exit(1) } + // TODO: In future release replace following logic with the refactored functions seen below. sigCh := make(chan os.Signal, 1) setupShutdownNotify(sigCh) @@ -435,19 +442,473 @@ func init() { RootCmd.AddCommand(RunCmd) } +func executeRun(runFilePath string, apps []runfileconfig.App) (bool, error) { + var exitWithError bool + + // setup shutdown notify channel. + sigCh := make(chan os.Signal, 1) + setupShutdownNotify(sigCh) + + runStates := make([]*runExec.RunExec, 0, len(apps)) + for _, app := range apps { + print.StatusEvent(os.Stdout, print.LogInfo, "Validating config and starting app %q", app.RunConfig.AppID) + // Set defaults if zero value provided in config yaml. + app.RunConfig.SetDefaultFromSchema() + + // Validate validates the configs and modifies the ports to free ports, appId etc. + err := app.RunConfig.Validate() + if err != nil { + print.FailureStatusEvent(os.Stderr, "Error validating run config for app %q present in %s: %s", app.RunConfig.AppID, runFilePath, err.Error()) + exitWithError = true + break + } + + // Get Run Config for different apps. + runConfig := app.RunConfig + err = app.CreateDaprdLogFile() + if err != nil { + print.StatusEvent(os.Stderr, print.LogFailure, "Error getting log file for app %q present in %s: %s", runConfig.AppID, runFilePath, err.Error()) + exitWithError = true + break + } + // Combined multiwriter for logs. + var appDaprdWriter io.Writer + // appLogWriterCloser is used when app command is present. + var appLogWriterCloser io.WriteCloser + daprdLogWriterCloser := app.DaprdLogWriteCloser + if len(runConfig.Command) == 0 { + print.StatusEvent(os.Stdout, print.LogWarning, "No application command found for app %q present in %s", runConfig.AppID, runFilePath) + appDaprdWriter = app.DaprdLogWriteCloser + appLogWriterCloser = app.DaprdLogWriteCloser + } else { + err = app.CreateAppLogFile() + if err != nil { + print.StatusEvent(os.Stderr, print.LogFailure, "Error getting log file for app %q present in %s: %s", runConfig.AppID, runFilePath, err.Error()) + exitWithError = true + break + } + appDaprdWriter = io.MultiWriter(app.AppLogWriteCloser, app.DaprdLogWriteCloser) + appLogWriterCloser = app.AppLogWriteCloser + } + + runState, err := startDaprdAndAppProcesses(&runConfig, app.AppDirPath, sigCh, + daprdLogWriterCloser, daprdLogWriterCloser, appLogWriterCloser, appLogWriterCloser) + if err != nil { + print.StatusEvent(os.Stdout, print.LogFailure, "Error starting Dapr and app (%q): %s", app.AppID, err.Error()) + print.StatusEvent(appDaprdWriter, print.LogFailure, "Error starting Dapr and app (%q): %s", app.AppID, err.Error()) + exitWithError = true + break + } + // Store runState in an array. + runStates = append(runStates, runState) + + // Metadata API is only available if app has started listening to port, so wait for app to start before calling metadata API. + // The PID is put as 0, so as to not kill CLI process when any one of the apps is stopped. + _ = putCLIProcessIDInMeta(runState, 0) + + if runState.AppCMD.Command != nil { + _ = putAppCommandInMeta(runConfig, runState) + } + print.StatusEvent(runState.DaprCMD.OutputWriter, print.LogSuccess, "You're up and running! Dapr logs will appear here.\n") + logInformationalStatusToStdout(app) + } + // If all apps have been started and there are no errors in starting the apps wait for signal from sigCh. + if !exitWithError { + // After all apps started wait for sigCh. + <-sigCh + // To add a new line in Stdout. + fmt.Println() + print.InfoStatusEvent(os.Stdout, "Received signal to stop Dapr and app processes. Shutting down Dapr and app processes.") + } + + // Stop daprd and app processes for each runState. + _, closeError := gracefullyShutdownAppsAndCloseResources(runStates, apps) + + for _, app := range apps { + runConfig := app.RunConfig + if runConfig.UnixDomainSocket != "" { + for _, s := range []string{"http", "grpc"} { + os.Remove(utils.GetSocket(runConfig.UnixDomainSocket, runConfig.AppID, s)) + } + } + } + + return exitWithError, closeError +} + +func logInformationalStatusToStdout(app runfileconfig.App) { + print.InfoStatusEvent(os.Stdout, "Started Dapr with app id %q. HTTP Port: %d. gRPC Port: %d", + app.AppID, app.RunConfig.HTTPPort, app.RunConfig.GRPCPort) + print.InfoStatusEvent(os.Stdout, "Writing log files to directory : %s", app.GetLogsDir()) +} + +func gracefullyShutdownAppsAndCloseResources(runState []*runExec.RunExec, apps []runfileconfig.App) (bool, error) { + exitWithError := false + for _, s := range runState { + hasErr := stopDaprdAndAppProcesses(s) + if !exitWithError && hasErr { + exitWithError = true + } + } + var err error + // close log file resources. + for _, app := range apps { + hasErr := app.CloseAppLogFile() + if err == nil && hasErr != nil { + err = hasErr + } + hasErr = app.CloseDaprdLogFile() + if err == nil && hasErr != nil { + err = hasErr + } + } + return exitWithError, err +} + func executeRunWithAppsConfigFile(runFilePath string) { config := runfileconfig.RunFileConfig{} apps, err := config.GetApps(runFilePath) if err != nil { - print.FailureStatusEvent(os.Stdout, fmt.Sprintf("Error getting apps from config file: %s", err)) + print.StatusEvent(os.Stdout, print.LogFailure, "Error getting apps from config file: %s", err) os.Exit(1) } if len(apps) == 0 { - print.FailureStatusEvent(os.Stdout, "No apps to run") + print.StatusEvent(os.Stdout, print.LogFailure, "No apps to run") + os.Exit(1) + } + exitWithError, closeErr := executeRun(runFilePath, apps) + if exitWithError { + if closeErr != nil { + print.StatusEvent(os.Stdout, print.LogFailure, "Error closing resources: %s", closeErr) + } os.Exit(1) } } +// startDaprdAndAppProcesses is a function to start the App process and the associated Daprd process. +// This should be called as a blocking function call. +func startDaprdAndAppProcesses(runConfig *standalone.RunConfig, commandDir string, sigCh chan os.Signal, + daprdOutputWriter io.Writer, daprdErrorWriter io.Writer, + appOutputWriter io.Writer, appErrorWriter io.Writer, +) (*runExec.RunExec, error) { + daprRunning := make(chan bool, 1) + appRunning := make(chan bool, 1) + + daprCMD, err := runExec.GetDaprCmdProcess(runConfig) + if err != nil { + print.StatusEvent(daprdErrorWriter, print.LogFailure, "Error getting daprd command with args : %s", err.Error()) + return nil, err + } + if strings.TrimSpace(commandDir) != "" { + daprCMD.Command.Dir = commandDir + } + daprCMD.WithOutputWriter(daprdOutputWriter) + daprCMD.WithErrorWriter(daprdErrorWriter) + daprCMD.SetStdout() + daprCMD.SetStderr() + + appCmd, err := runExec.GetAppCmdProcess(runConfig) + if err != nil { + print.StatusEvent(appErrorWriter, print.LogFailure, "Error getting app command with args : %s", err.Error()) + return nil, err + } + if appCmd.Command != nil { + // If an app is being run, set the command directory for that app. + // appCmd does not need to call SetStdout and SetStderr since output is being read processed and then written + // to the output and error writers for an app. + appCmd.WithOutputWriter(appOutputWriter) + appCmd.WithErrorWriter(appErrorWriter) + if strings.TrimSpace(commandDir) != "" { + appCmd.Command.Dir = commandDir + } + } + + runState := runExec.New(runConfig, daprCMD, appCmd) + + startErrChan := make(chan error, 1) + + // Start daprd process. + go startDaprdProcess(runConfig, runState, daprRunning, sigCh, startErrChan) + + // Wait for daprRunning channel output. + if daprStarted := <-daprRunning; !daprStarted { + startErr := <-startErrChan + print.StatusEvent(daprdErrorWriter, print.LogFailure, "Error starting daprd process: %s", startErr.Error()) + return nil, startErr + } + + // No application command is present. + if appCmd.Command == nil { + print.StatusEvent(appOutputWriter, print.LogWarning, "No application command present") + return runState, nil + } + + // Start App process. + go startAppProcess(runConfig, runState, appRunning, sigCh, startErrChan) + + // Wait for appRunnning channel output. + if appStarted := <-appRunning; !appStarted { + startErr := <-startErrChan + print.StatusEvent(appErrorWriter, print.LogFailure, "Error starting app process: %s", startErr.Error()) + // Start App failed so try to stop daprd process. + err = killDaprdProcess(runState) + if err != nil { + print.StatusEvent(daprdErrorWriter, print.LogFailure, "Error stopping daprd process: %s", err.Error()) + print.StatusEvent(appErrorWriter, print.LogFailure, "Error stopping daprd process: %s", err.Error()) + } + // Return the error from starting the app process. + return nil, startErr + } + return runState, nil +} + +// stopDaprdAndAppProcesses is a function to stop the App process and the associated Daprd process +// This should be called as a blocking function call. +func stopDaprdAndAppProcesses(runState *runExec.RunExec) bool { + var err error + print.StatusEvent(runState.DaprCMD.OutputWriter, print.LogInfo, "\ntermination signal received: shutting down") + // Only if app command is present and + // if two different output writers are present run the following print statement. + if runState.AppCMD.Command != nil && runState.AppCMD.OutputWriter != runState.DaprCMD.OutputWriter { + print.StatusEvent(runState.AppCMD.OutputWriter, print.LogInfo, "\ntermination signal received: shutting down") + } + + exitWithError := false + + daprErr := runState.DaprCMD.CommandErr + + if daprErr != nil { + exitWithError = true + print.StatusEvent(runState.DaprCMD.ErrorWriter, print.LogFailure, "Error exiting Dapr: %s", daprErr) + } else if runState.DaprCMD.Command.ProcessState == nil || !runState.DaprCMD.Command.ProcessState.Exited() { + err = killDaprdProcess(runState) + if err != nil { + exitWithError = true + } + } + appErr := runState.AppCMD.CommandErr + + if appErr != nil { + exitWithError = true + print.StatusEvent(runState.AppCMD.ErrorWriter, print.LogFailure, "Error exiting App: %s", appErr) + } else if runState.AppCMD.Command != nil && (runState.AppCMD.Command.ProcessState == nil || !runState.AppCMD.Command.ProcessState.Exited()) { + err = killAppProcess(runState) + if err != nil { + exitWithError = true + } + } + return exitWithError +} + +// startAppsProcess, starts the App process and calls wait in a goroutine +// This function should be called as a goroutine. +func startAppProcess(runConfig *standalone.RunConfig, runE *runExec.RunExec, + appRunning chan bool, sigCh chan os.Signal, errorChan chan error, +) { + if runE.AppCMD.Command == nil { + appRunning <- true + return + } + + stdErrPipe, pipeErr := runE.AppCMD.Command.StderrPipe() + if pipeErr != nil { + print.StatusEvent(runE.AppCMD.ErrorWriter, print.LogFailure, "Error creating stderr for App %q : %s", runE.AppID, pipeErr.Error()) + errorChan <- pipeErr + appRunning <- false + return + } + + stdOutPipe, pipeErr := runE.AppCMD.Command.StdoutPipe() + if pipeErr != nil { + print.StatusEvent(runE.AppCMD.ErrorWriter, print.LogFailure, "Error creating stdout for App %q : %s", runE.AppID, pipeErr.Error()) + errorChan <- pipeErr + appRunning <- false + return + } + + errScanner := bufio.NewScanner(stdErrPipe) + outScanner := bufio.NewScanner(stdOutPipe) + go func() { + for errScanner.Scan() { + if runE.AppCMD.ErrorWriter == os.Stderr { + // Add color and prefix to log and output to Stderr. + fmt.Fprintln(runE.AppCMD.ErrorWriter, print.Blue(fmt.Sprintf("== APP == %s", errScanner.Text()))) + } else { + // Directly output app logs to the error writer. + fmt.Fprintln(runE.AppCMD.ErrorWriter, errScanner.Text()) + } + } + }() + + go func() { + for outScanner.Scan() { + if runE.AppCMD.OutputWriter == os.Stdout { + // Add color and prefix to log and output to Stdout. + fmt.Fprintln(runE.AppCMD.OutputWriter, print.Blue(fmt.Sprintf("== APP == %s", outScanner.Text()))) + } else { + // Directly output app logs to the output writer. + fmt.Fprintln(runE.AppCMD.OutputWriter, outScanner.Text()) + } + } + }() + + err := runE.AppCMD.Command.Start() + if err != nil { + print.StatusEvent(runE.AppCMD.ErrorWriter, print.LogFailure, err.Error()) + errorChan <- err + appRunning <- false + return + } + + go func() { + appErr := runE.AppCMD.Command.Wait() + + if appErr != nil { + runE.AppCMD.CommandErr = appErr + print.StatusEvent(runE.AppCMD.ErrorWriter, print.LogFailure, "The App process exited with error code: %s", appErr.Error()) + } else { + print.StatusEvent(runE.AppCMD.OutputWriter, print.LogSuccess, "Exited App successfully") + } + }() + + appRunning <- true +} + +// startDaprdProcess, starts the Daprd process and calls wait in a goroutine +// This function should be called as a goroutine. +func startDaprdProcess(runConfig *standalone.RunConfig, runE *runExec.RunExec, + daprRunning chan bool, sigCh chan os.Signal, errorChan chan error, +) { + var startInfo string + if runConfig.UnixDomainSocket != "" { + startInfo = fmt.Sprintf( + "Starting Dapr with id %s. HTTP Socket: %v. gRPC Socket: %v.", + runE.AppID, + utils.GetSocket(unixDomainSocket, runE.AppID, "http"), + utils.GetSocket(unixDomainSocket, runE.AppID, "grpc")) + } else { + startInfo = fmt.Sprintf( + "Starting Dapr with id %s. HTTP Port: %v. gRPC Port: %v", + runE.AppID, + runE.DaprHTTPPort, + runE.DaprGRPCPort) + } + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogInfo, startInfo) + + err := runE.DaprCMD.Command.Start() + if err != nil { + errorChan <- err + daprRunning <- false + return + } + + go func() { + daprdErr := runE.DaprCMD.Command.Wait() + + if daprdErr != nil { + runE.DaprCMD.CommandErr = daprdErr + print.StatusEvent(runE.DaprCMD.ErrorWriter, print.LogFailure, "The daprd process exited with error code: %s", daprdErr.Error()) + } else { + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogSuccess, "Exited Dapr successfully") + } + }() + + if runConfig.AppPort <= 0 { + // If app does not listen to port, we can check for Dapr's sidecar health before starting the app. + // Otherwise, it creates a deadlock. + sidecarUp := true + + if runConfig.UnixDomainSocket != "" { + httpSocket := utils.GetSocket(runConfig.UnixDomainSocket, runE.AppID, "http") + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogInfo, "Checking if Dapr sidecar is listening on HTTP socket %v", httpSocket) + err = utils.IsDaprListeningOnSocket(httpSocket, time.Duration(runtimeWaitTimeoutInSeconds)*time.Second) + if err != nil { + sidecarUp = false + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogWarning, "Dapr sidecar is not listening on HTTP socket: %s", err.Error()) + } + + grpcSocket := utils.GetSocket(runConfig.UnixDomainSocket, runE.AppID, "grpc") + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogInfo, "Checking if Dapr sidecar is listening on GRPC socket %v", grpcSocket) + err = utils.IsDaprListeningOnSocket(grpcSocket, time.Duration(runtimeWaitTimeoutInSeconds)*time.Second) + if err != nil { + sidecarUp = false + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogWarning, "Dapr sidecar is not listening on GRPC socket: %s", err.Error()) + } + } else { + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogInfo, "Checking if Dapr sidecar is listening on HTTP port %v", runE.DaprHTTPPort) + err = utils.IsDaprListeningOnPort(runE.DaprHTTPPort, time.Duration(runtimeWaitTimeoutInSeconds)*time.Second) + if err != nil { + sidecarUp = false + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogWarning, "Dapr sidecar is not listening on HTTP port: %s", err.Error()) + } + + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogInfo, "Checking if Dapr sidecar is listening on GRPC port %v", runE.DaprGRPCPort) + err = utils.IsDaprListeningOnPort(runE.DaprGRPCPort, time.Duration(runtimeWaitTimeoutInSeconds)*time.Second) + if err != nil { + sidecarUp = false + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogWarning, "Dapr sidecar is not listening on GRPC port: %s", err.Error()) + } + } + + if sidecarUp { + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogInfo, "Dapr sidecar is up and running.") + } else { + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogWarning, "Dapr sidecar might not be responding.") + } + } + daprRunning <- true +} + +// killDaprdProcess is used to kill the Daprd process and return error on failure. +func killDaprdProcess(runE *runExec.RunExec) error { + err := runE.DaprCMD.Command.Process.Kill() + if err != nil { + print.StatusEvent(runE.DaprCMD.ErrorWriter, print.LogFailure, "Error exiting Dapr: %s", err) + return err + } + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogSuccess, "Exited Dapr successfully") + return nil +} + +// killAppProcess is used to kill the App process and return error on failure. +func killAppProcess(runE *runExec.RunExec) error { + if runE.AppCMD.Command == nil { + return nil + } + err := runE.AppCMD.Command.Process.Kill() + if err != nil { + print.StatusEvent(runE.DaprCMD.ErrorWriter, print.LogFailure, "Error exiting App: %s", err) + return err + } + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogSuccess, "Exited App successfully") + return nil +} + +// putCLIProcessIDInMeta puts the CLI process ID in metadata so that it can be used by the CLI to stop the CLI process. +func putCLIProcessIDInMeta(runE *runExec.RunExec, pid int) error { + // For now putting this as 0, since we do not want the dapr stop command for a single to kill the CLI process, + // thereby killing all the apps that are running via dapr run -f. + err := metadata.Put(runE.DaprHTTPPort, "cliPID", strconv.Itoa(pid), runE.AppID, unixDomainSocket) + if err != nil { + print.StatusEvent(runE.DaprCMD.OutputWriter, print.LogWarning, "Could not update sidecar metadata for cliPID: %s", err.Error()) + return err + } + return nil +} + +// putAppCommandInMeta puts the app command in metadata so that it can be used by the CLI to stop the app. +func putAppCommandInMeta(runConfig standalone.RunConfig, runState *runExec.RunExec) error { + appCommand := strings.Join(runConfig.Command, " ") + print.StatusEvent(runState.DaprCMD.OutputWriter, print.LogInfo, "Updating metadata for app command: %s", appCommand) + err := metadata.Put(runState.DaprHTTPPort, "appCommand", appCommand, runState.AppID, runConfig.UnixDomainSocket) + if err != nil { + print.StatusEvent(runState.DaprCMD.OutputWriter, print.LogWarning, "Could not update sidecar metadata for appCommand: %s", err.Error()) + return err + } + print.StatusEvent(runState.DaprCMD.OutputWriter, print.LogSuccess, "You're up and running! Dapr logs will appear here.\n") + return nil +} + // getRunFilePath returns the path to the run file. // If the provided path is a path to a YAML file then return the same. // Else it returns the path of "dapr.yaml" in the provided directory. diff --git a/pkg/kubernetes/configurations_test.go b/pkg/kubernetes/configurations_test.go index 4f16cd0b2..ce00562d8 100644 --- a/pkg/kubernetes/configurations_test.go +++ b/pkg/kubernetes/configurations_test.go @@ -24,6 +24,7 @@ import ( v1alpha1 "github.com/dapr/dapr/pkg/apis/configuration/v1alpha1" ) +//nolint:dupword func TestConfigurations(t *testing.T) { now := meta_v1.Now() formattedNow := now.Format("2006-01-02 15:04.05") diff --git a/pkg/print/print.go b/pkg/print/print.go index 42e4a6bdc..a5757a47a 100644 --- a/pkg/print/print.go +++ b/pkg/print/print.go @@ -17,6 +17,7 @@ import ( "encoding/json" "fmt" "io" + "os" "runtime" "sync" "time" @@ -29,6 +30,16 @@ const ( windowsOS = "windows" ) +type logStatus string + +const ( + LogSuccess logStatus = "success" + LogFailure logStatus = "failure" + LogWarning logStatus = "warning" + LogInfo logStatus = "info" + LogPending logStatus = "pending" +) + type Result bool const ( @@ -56,10 +67,36 @@ func IsJSONLogEnabled() bool { return logAsJSON } +// StatusEvent reports a event log with given status. +func StatusEvent(w io.Writer, status logStatus, fmtstr string, a ...any) { + if logAsJSON { + logJSON(w, string(status), fmt.Sprintf(fmtstr, a...)) + return + } + if (w != os.Stdout && w != os.Stderr) || runtime.GOOS == windowsOS { + fmt.Fprintf(w, "%s\n", fmt.Sprintf(fmtstr, a...)) + return + } + switch status { + case LogSuccess: + fmt.Fprintf(w, "✅ %s\n", fmt.Sprintf(fmtstr, a...)) + case LogFailure: + fmt.Fprintf(w, "❌ %s\n", fmt.Sprintf(fmtstr, a...)) + case LogWarning: + fmt.Fprintf(w, "⚠ %s\n", fmt.Sprintf(fmtstr, a...)) + case LogPending: + fmt.Fprintf(w, "⌛ %s\n", fmt.Sprintf(fmtstr, a...)) + case LogInfo: + fmt.Fprintf(w, "ℹ️ %s\n", fmt.Sprintf(fmtstr, a...)) + default: + fmt.Fprintf(w, "%s\n", fmt.Sprintf(fmtstr, a...)) + } +} + // SuccessStatusEvent reports on a success event. func SuccessStatusEvent(w io.Writer, fmtstr string, a ...interface{}) { if logAsJSON { - logJSON(w, "success", fmt.Sprintf(fmtstr, a...)) + logJSON(w, string(LogSuccess), fmt.Sprintf(fmtstr, a...)) } else if runtime.GOOS == windowsOS { fmt.Fprintf(w, "%s\n", fmt.Sprintf(fmtstr, a...)) } else { @@ -70,7 +107,7 @@ func SuccessStatusEvent(w io.Writer, fmtstr string, a ...interface{}) { // FailureStatusEvent reports on a failure event. func FailureStatusEvent(w io.Writer, fmtstr string, a ...interface{}) { if logAsJSON { - logJSON(w, "failure", fmt.Sprintf(fmtstr, a...)) + logJSON(w, string(LogFailure), fmt.Sprintf(fmtstr, a...)) } else if runtime.GOOS == windowsOS { fmt.Fprintf(w, "%s\n", fmt.Sprintf(fmtstr, a...)) } else { @@ -81,7 +118,7 @@ func FailureStatusEvent(w io.Writer, fmtstr string, a ...interface{}) { // WarningStatusEvent reports on a failure event. func WarningStatusEvent(w io.Writer, fmtstr string, a ...interface{}) { if logAsJSON { - logJSON(w, "warning", fmt.Sprintf(fmtstr, a...)) + logJSON(w, string(LogWarning), fmt.Sprintf(fmtstr, a...)) } else if runtime.GOOS == windowsOS { fmt.Fprintf(w, "%s\n", fmt.Sprintf(fmtstr, a...)) } else { @@ -92,7 +129,7 @@ func WarningStatusEvent(w io.Writer, fmtstr string, a ...interface{}) { // PendingStatusEvent reports on a pending event. func PendingStatusEvent(w io.Writer, fmtstr string, a ...interface{}) { if logAsJSON { - logJSON(w, "pending", fmt.Sprintf(fmtstr, a...)) + logJSON(w, string(LogPending), fmt.Sprintf(fmtstr, a...)) } else if runtime.GOOS == windowsOS { fmt.Fprintf(w, "%s\n", fmt.Sprintf(fmtstr, a...)) } else { @@ -103,7 +140,7 @@ func PendingStatusEvent(w io.Writer, fmtstr string, a ...interface{}) { // InfoStatusEvent reports status information on an event. func InfoStatusEvent(w io.Writer, fmtstr string, a ...interface{}) { if logAsJSON { - logJSON(w, "info", fmt.Sprintf(fmtstr, a...)) + logJSON(w, string(LogInfo), fmt.Sprintf(fmtstr, a...)) } else if runtime.GOOS == windowsOS { fmt.Fprintf(w, "%s\n", fmt.Sprintf(fmtstr, a...)) } else { @@ -117,7 +154,7 @@ func Spinner(w io.Writer, fmtstr string, a ...interface{}) func(result Result) { var s *spinner.Spinner if logAsJSON { - logJSON(w, "pending", msg) + logJSON(w, string(LogPending), msg) } else if runtime.GOOS == windowsOS { fmt.Fprintf(w, "%s\n", msg) diff --git a/pkg/runexec/runexec.go b/pkg/runexec/runexec.go new file mode 100644 index 000000000..abf066705 --- /dev/null +++ b/pkg/runexec/runexec.go @@ -0,0 +1,131 @@ +/* +Copyright 2023 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 runexec + +import ( + "fmt" + "io" + "os/exec" + + "github.com/dapr/cli/pkg/standalone" +) + +type CmdProcess struct { + Command *exec.Cmd + CommandErr error + OutputWriter io.Writer + ErrorWriter io.Writer +} + +type RunExec struct { + DaprCMD *CmdProcess + AppCMD *CmdProcess + AppID string + DaprHTTPPort int + DaprGRPCPort int + DaprMetricPort int +} + +// RunOutput represents the run execution. +type RunOutput struct { + DaprCMD *exec.Cmd + DaprErr error + DaprHTTPPort int + DaprGRPCPort int + AppID string + AppCMD *exec.Cmd + AppErr error +} + +func New(config *standalone.RunConfig, daprCmdProcess *CmdProcess, appCmdProcess *CmdProcess) *RunExec { + return &RunExec{ + DaprCMD: daprCmdProcess, + AppCMD: appCmdProcess, + AppID: config.AppID, + DaprHTTPPort: config.HTTPPort, + DaprGRPCPort: config.GRPCPort, + DaprMetricPort: config.MetricsPort, + } +} + +func GetDaprCmdProcess(config *standalone.RunConfig) (*CmdProcess, error) { + daprCMD, err := standalone.GetDaprCommand(config) + if err != nil { + return nil, err + } + return &CmdProcess{ + Command: daprCMD, + }, nil +} + +func GetAppCmdProcess(config *standalone.RunConfig) (*CmdProcess, error) { + //nolint + var appCMD *exec.Cmd = standalone.GetAppCommand(config) + return &CmdProcess{ + Command: appCMD, + }, nil +} + +func (c *CmdProcess) WithOutputWriter(w io.Writer) { + c.OutputWriter = w +} + +// SetStdout should be called after WithOutputWriter. +func (c *CmdProcess) SetStdout() error { + if c.Command == nil { + return fmt.Errorf("command is nil") + } + c.Command.Stdout = c.OutputWriter + return nil +} + +func (c *CmdProcess) WithErrorWriter(w io.Writer) { + c.ErrorWriter = w +} + +// SetStdErr should be called after WithErrorWriter. +func (c *CmdProcess) SetStderr() error { + if c.Command == nil { + return fmt.Errorf("command is nil") + } + c.Command.Stderr = c.ErrorWriter + return nil +} + +func NewOutput(config *standalone.RunConfig) (*RunOutput, error) { + // set default values from RunConfig struct's tag. + config.SetDefaultFromSchema() + //nolint + err := config.Validate() + if err != nil { + return nil, err + } + + daprCMD, err := standalone.GetDaprCommand(config) + if err != nil { + return nil, err + } + + //nolint + var appCMD *exec.Cmd = standalone.GetAppCommand(config) + return &RunOutput{ + DaprCMD: daprCMD, + DaprErr: nil, + AppCMD: appCMD, + AppErr: nil, + AppID: config.AppID, + DaprHTTPPort: config.HTTPPort, + DaprGRPCPort: config.GRPCPort, + }, nil +} diff --git a/pkg/standalone/run_test.go b/pkg/runexec/runexec_test.go similarity index 86% rename from pkg/standalone/run_test.go rename to pkg/runexec/runexec_test.go index fdd3de99e..6a99365d7 100644 --- a/pkg/standalone/run_test.go +++ b/pkg/runexec/runexec_test.go @@ -1,5 +1,5 @@ /* -Copyright 2021 The Dapr Authors +Copyright 2023 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 @@ -11,7 +11,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package standalone +package runexec import ( "fmt" @@ -21,6 +21,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/dapr/cli/pkg/standalone" ) func assertArgumentEqual(t *testing.T, key string, expectedValue string, args []string) { @@ -72,11 +74,11 @@ func assertArgumentContains(t *testing.T, key string, expectedValue string, args } func setupRun(t *testing.T) { - myDaprPath, err := GetDaprPath("") + myDaprPath, err := standalone.GetDaprPath("") assert.NoError(t, err) - componentsDir := GetDaprComponentsPath(myDaprPath) - configFile := GetDaprConfigPath(myDaprPath) + componentsDir := standalone.GetDaprComponentsPath(myDaprPath) + configFile := standalone.GetDaprConfigPath(myDaprPath) err = os.MkdirAll(componentsDir, 0o700) assert.Equal(t, nil, err, "Unable to setup components dir before running test") file, err := os.Create(configFile) @@ -85,11 +87,11 @@ func setupRun(t *testing.T) { } func tearDownRun(t *testing.T) { - myDaprPath, err := GetDaprPath("") + myDaprPath, err := standalone.GetDaprPath("") assert.NoError(t, err) - componentsDir := GetDaprComponentsPath(myDaprPath) - configFile := GetDaprConfigPath(myDaprPath) + componentsDir := standalone.GetDaprComponentsPath(myDaprPath) + configFile := standalone.GetDaprConfigPath(myDaprPath) err = os.RemoveAll(componentsDir) assert.Equal(t, nil, err, "Unable to delete default components dir after running test") @@ -97,14 +99,14 @@ func tearDownRun(t *testing.T) { assert.Equal(t, nil, err, "Unable to delete default config file after running test") } -func assertCommonArgs(t *testing.T, basicConfig *RunConfig, output *RunOutput) { +func assertCommonArgs(t *testing.T, basicConfig *standalone.RunConfig, output *RunOutput) { assert.NotNil(t, output) assert.Equal(t, "MyID", output.AppID) assert.Equal(t, 8000, output.DaprHTTPPort) assert.Equal(t, 50001, output.DaprGRPCPort) - daprPath, err := GetDaprPath("") + daprPath, err := standalone.GetDaprPath("") assert.NoError(t, err) assert.Contains(t, output.DaprCMD.Args[0], "daprd") @@ -115,7 +117,7 @@ func assertCommonArgs(t *testing.T, basicConfig *RunConfig, output *RunOutput) { assertArgumentEqual(t, "app-max-concurrency", "-1", output.DaprCMD.Args) assertArgumentEqual(t, "app-protocol", "http", output.DaprCMD.Args) assertArgumentEqual(t, "app-port", "3000", output.DaprCMD.Args) - assertArgumentEqual(t, "components-path", GetDaprComponentsPath(daprPath), output.DaprCMD.Args) + assertArgumentEqual(t, "components-path", standalone.GetDaprComponentsPath(daprPath), output.DaprCMD.Args) assertArgumentEqual(t, "app-ssl", "", output.DaprCMD.Args) assertArgumentEqual(t, "metrics-port", "9001", output.DaprCMD.Args) assertArgumentEqual(t, "dapr-http-max-request-size", "-1", output.DaprCMD.Args) @@ -124,7 +126,7 @@ func assertCommonArgs(t *testing.T, basicConfig *RunConfig, output *RunOutput) { assertArgumentEqual(t, "dapr-listen-addresses", "127.0.0.1", output.DaprCMD.Args) } -func assertAppEnv(t *testing.T, config *RunConfig, output *RunOutput) { +func assertAppEnv(t *testing.T, config *standalone.RunConfig, output *RunOutput) { envSet := make(map[string]bool) for _, env := range output.AppCMD.Env { envSet[env] = true @@ -139,7 +141,7 @@ func assertAppEnv(t *testing.T, config *RunConfig, output *RunOutput) { } } -func getEnvSet(config *RunConfig) []string { +func getEnvSet(config *standalone.RunConfig) []string { set := []string{ getEnv("DAPR_GRPC_PORT", config.GRPCPort), getEnv("DAPR_HTTP_PORT", config.HTTPPort), @@ -166,13 +168,13 @@ func TestRun(t *testing.T) { // Setup the tearDown routine to run in the end. defer tearDownRun(t) - myDaprPath, err := GetDaprPath("") + myDaprPath, err := standalone.GetDaprPath("") assert.NoError(t, err) - componentsDir := GetDaprComponentsPath(myDaprPath) - configFile := GetDaprConfigPath(myDaprPath) + componentsDir := standalone.GetDaprComponentsPath(myDaprPath) + configFile := standalone.GetDaprConfigPath(myDaprPath) - sharedRunConfig := &SharedRunConfig{ + sharedRunConfig := &standalone.SharedRunConfig{ LogLevel: "WARN", EnableProfiling: false, AppProtocol: "http", @@ -183,7 +185,7 @@ func TestRun(t *testing.T) { EnableAPILogging: true, APIListenAddresses: "127.0.0.1", } - basicConfig := &RunConfig{ + basicConfig := &standalone.RunConfig{ AppID: "MyID", AppPort: 3000, HTTPPort: 8000, @@ -196,7 +198,7 @@ func TestRun(t *testing.T) { } t.Run("run happy http", func(t *testing.T) { - output, err := Run(basicConfig) + output, err := NewOutput(basicConfig) assert.NoError(t, err) assertCommonArgs(t, basicConfig, output) @@ -210,11 +212,11 @@ func TestRun(t *testing.T) { basicConfig.LogLevel = "INFO" basicConfig.EnableAPILogging = true basicConfig.ConfigFile = configFile - output, err := Run(basicConfig) + output, err := NewOutput(basicConfig) assert.NoError(t, err) assertCommonArgs(t, basicConfig, output) - assertArgumentContains(t, "config", DefaultConfigFileName, output.DaprCMD.Args) + assertArgumentContains(t, "config", standalone.DefaultConfigFileName, output.DaprCMD.Args) assert.Nil(t, output.AppCMD) }) @@ -222,7 +224,7 @@ func TestRun(t *testing.T) { basicConfig.HTTPPort = -1 basicConfig.GRPCPort = -1 basicConfig.MetricsPort = -1 - output, err := Run(basicConfig) + output, err := NewOutput(basicConfig) assert.NoError(t, err) assert.NotNil(t, output) @@ -233,7 +235,7 @@ func TestRun(t *testing.T) { }) t.Run("app health check flags missing if not set", func(t *testing.T) { - output, err := Run(basicConfig) + output, err := NewOutput(basicConfig) assert.NoError(t, err) assert.NotNil(t, output) @@ -248,7 +250,7 @@ func TestRun(t *testing.T) { t.Run("enable app health checks with default flags", func(t *testing.T) { basicConfig.EnableAppHealth = true - output, err := Run(basicConfig) + output, err := NewOutput(basicConfig) assert.NoError(t, err) assert.NotNil(t, output) @@ -269,7 +271,7 @@ func TestRun(t *testing.T) { basicConfig.AppHealthTimeout = 200 basicConfig.AppHealthThreshold = 1 basicConfig.AppHealthPath = "/foo" - output, err := Run(basicConfig) + output, err := NewOutput(basicConfig) assert.NoError(t, err) assert.NotNil(t, output) @@ -294,7 +296,7 @@ func TestRun(t *testing.T) { basicConfig.HTTPReadBufferSize = 0 basicConfig.AppProtocol = "" - basicConfig.setDefaultFromSchema() + basicConfig.SetDefaultFromSchema() assert.Equal(t, -1, basicConfig.AppPort) assert.True(t, basicConfig.HTTPPort == -1) @@ -308,7 +310,7 @@ func TestRun(t *testing.T) { assert.Equal(t, "http", basicConfig.AppProtocol) // Test after Validate gets called. - err := basicConfig.validate() + err := basicConfig.Validate() assert.NoError(t, err) assert.Equal(t, 0, basicConfig.AppPort) diff --git a/pkg/standalone/run.go b/pkg/standalone/run.go index e642340ce..02b8c6584 100644 --- a/pkg/standalone/run.go +++ b/pkg/standalone/run.go @@ -84,15 +84,19 @@ func (meta *DaprMeta) newAppID() string { } } -func (config *RunConfig) validateComponentPath() error { - _, err := os.Stat(config.ComponentsPath) +func (config *RunConfig) validateResourcesPath() error { + dirPath := config.ResourcesPath + if dirPath == "" { + dirPath = config.ComponentsPath + } + _, err := os.Stat(dirPath) if err != nil { - return err + return fmt.Errorf("error validating resources path %q : %w", dirPath, err) } - componentsLoader := components.NewStandaloneComponents(modes.StandaloneConfig{ComponentsPath: config.ComponentsPath}) + componentsLoader := components.NewStandaloneComponents(modes.StandaloneConfig{ComponentsPath: dirPath}) _, err = componentsLoader.LoadComponents() if err != nil { - return err + return fmt.Errorf("error validating components in resources path %q : %w", dirPath, err) } return nil } @@ -129,7 +133,7 @@ func (config *RunConfig) validatePort(portName string, portPtr *int, meta *DaprM return nil } -func (config *RunConfig) validate() error { +func (config *RunConfig) Validate() error { meta, err := newDaprMeta() if err != nil { return err @@ -139,7 +143,7 @@ func (config *RunConfig) validate() error { config.AppID = meta.newAppID() } - err = config.validateComponentPath() + err = config.validateResourcesPath() if err != nil { return err } @@ -298,7 +302,7 @@ func getArgsFromSchema(schema reflect.Value, args []string) []string { return args } -func (config *RunConfig) setDefaultFromSchema() { +func (config *RunConfig) SetDefaultFromSchema() { schema := reflect.ValueOf(*config) config.setDefaultFromSchemaRecursive(schema) } @@ -349,18 +353,7 @@ func (config *RunConfig) getEnv() []string { return env } -// RunOutput represents the run output. -type RunOutput struct { - DaprCMD *exec.Cmd - DaprErr error - DaprHTTPPort int - DaprGRPCPort int - AppID string - AppCMD *exec.Cmd - AppErr error -} - -func getDaprCommand(config *RunConfig) (*exec.Cmd, error) { +func GetDaprCommand(config *RunConfig) (*exec.Cmd, error) { daprCMD, err := lookupBinaryFilePath(config.DaprdInstallPath, "daprd") if err != nil { return nil, err @@ -393,7 +386,7 @@ func mtlsEndpoint(configFile string) string { return "" } -func getAppCommand(config *RunConfig) *exec.Cmd { +func GetAppCommand(config *RunConfig) *exec.Cmd { argCount := len(config.Command) if argCount == 0 { @@ -412,30 +405,3 @@ func getAppCommand(config *RunConfig) *exec.Cmd { return cmd } - -func Run(config *RunConfig) (*RunOutput, error) { - // set default values from RunConfig struct's tag. - config.setDefaultFromSchema() - //nolint - err := config.validate() - if err != nil { - return nil, err - } - - daprCMD, err := getDaprCommand(config) - if err != nil { - return nil, err - } - - //nolint - var appCMD *exec.Cmd = getAppCommand(config) - return &RunOutput{ - DaprCMD: daprCMD, - DaprErr: nil, - AppCMD: appCMD, - AppErr: nil, - AppID: config.AppID, - DaprHTTPPort: config.HTTPPort, - DaprGRPCPort: config.GRPCPort, - }, nil -} diff --git a/pkg/standalone/runfileconfig/run_file_config.go b/pkg/standalone/runfileconfig/run_file_config.go index 4c4f257ba..6da92d338 100644 --- a/pkg/standalone/runfileconfig/run_file_config.go +++ b/pkg/standalone/runfileconfig/run_file_config.go @@ -13,23 +13,92 @@ limitations under the License. package runfileconfig -import "github.com/dapr/cli/pkg/standalone" +import ( + "io" + "os" + "path/filepath" + + "github.com/dapr/cli/pkg/standalone" +) + +const ( + appLogFileNamePrefix = "app" + daprdLogFileNamePrefix = "daprd" + logFileExtension = ".log" + logsDir = "logs" +) // RunFileConfig represents the complete configuration options for the run file. // It is meant to be used with - "dapr run --run-file " command. type RunFileConfig struct { Common Common `yaml:"common"` - Apps []Apps `yaml:"apps"` + Apps []App `yaml:"apps"` Version int `yaml:"version"` } -// Apps represents the configuration options for the apps in the run file. -type Apps struct { +// App represents the configuration options for the apps in the run file. +type App struct { standalone.RunConfig `yaml:",inline"` AppDirPath string `yaml:"app_dir_path"` + AppLogFileName string + DaprdLogFileName string + AppLogWriteCloser io.WriteCloser + DaprdLogWriteCloser io.WriteCloser } // Common represents the configuration options for the common section in the run file. type Common struct { standalone.SharedRunConfig `yaml:",inline"` } + +func (a *App) GetLogsDir() string { + logsPath := filepath.Join(a.AppDirPath, standalone.DefaultDaprDirName, logsDir) + os.MkdirAll(logsPath, 0o755) + return logsPath +} + +// CreateAppLogFile creates the log file, sets internal file handle +// and returns error if any. +func (a *App) CreateAppLogFile() error { + logsPath := a.GetLogsDir() + f, err := os.Create(filepath.Join(logsPath, getAppLogFileName())) + if err == nil { + a.AppLogWriteCloser = f + a.AppLogFileName = f.Name() + } + return err +} + +// CreateDaprdLogFile creates the log file, sets internal file handle +// and returns error if any. +func (a *App) CreateDaprdLogFile() error { + logsPath := a.GetLogsDir() + f, err := os.Create(filepath.Join(logsPath, getDaprdLogFileName())) + if err == nil { + a.DaprdLogWriteCloser = f + a.DaprdLogFileName = f.Name() + } + return err +} + +func getAppLogFileName() string { + return appLogFileNamePrefix + logFileExtension +} + +func getDaprdLogFileName() string { + return daprdLogFileNamePrefix + logFileExtension +} + +func (a *App) CloseAppLogFile() error { + if a.AppLogWriteCloser != nil { + return a.AppLogWriteCloser.Close() + } + return nil +} + +func (a *App) CloseDaprdLogFile() error { + if a.DaprdLogWriteCloser != nil { + return a.DaprdLogWriteCloser.Close() + } + return nil +} diff --git a/pkg/standalone/runfileconfig/run_file_config_parser.go b/pkg/standalone/runfileconfig/run_file_config_parser.go index c6d8ca134..3226777ea 100644 --- a/pkg/standalone/runfileconfig/run_file_config_parser.go +++ b/pkg/standalone/runfileconfig/run_file_config_parser.go @@ -65,7 +65,7 @@ func (a *RunFileConfig) validateRunConfig(runFilePath string) error { if err != nil { return err } - // All other paths present inside the specific app's in the YAML file, should be resolved relative to AppDirPath for that app. + // All other relative paths present inside the specific app's in the YAML file, should be resolved relative to AppDirPath for that app. err = a.resolvePathToAbsAndValidate(a.Apps[i].AppDirPath, &a.Apps[i].ConfigFile, &a.Apps[i].ResourcesPath, &a.Apps[i].DaprdInstallPath) if err != nil { return err @@ -76,7 +76,7 @@ func (a *RunFileConfig) validateRunConfig(runFilePath string) error { // GetApps orchestrates the parsing of supplied run file, validating fields and consolidating SharedRunConfig for the apps. // It returns a list of apps with the merged values for the SharedRunConfig from common section of the YAML file. -func (a *RunFileConfig) GetApps(runFilePath string) ([]Apps, error) { +func (a *RunFileConfig) GetApps(runFilePath string) ([]App, error) { err := a.parseAppsConfig(runFilePath) if err != nil { return nil, err @@ -199,7 +199,7 @@ func (a *RunFileConfig) mergeCommonAndAppsEnv() { // resolveResourcesFilePath resolves the resources path for the app. // Precedence order for resources_path -> apps[i].resources_path > apps[i].app_dir_path/.dapr/resources > common.resources_path > dapr default resources path. -func (a *RunFileConfig) resolveResourcesFilePath(app *Apps) error { +func (a *RunFileConfig) resolveResourcesFilePath(app *App) error { if app.ResourcesPath != "" { return nil } @@ -223,7 +223,7 @@ func (a *RunFileConfig) resolveResourcesFilePath(app *Apps) error { // resolveConfigFilePath resolves the config file path for the app. // Precedence order for config_file -> apps[i].config_file > apps[i].app_dir_path/.dapr/config.yaml > common.config_file > dapr default config file. -func (a *RunFileConfig) resolveConfigFilePath(app *Apps) error { +func (a *RunFileConfig) resolveConfigFilePath(app *App) error { if app.ConfigFile != "" { return nil } diff --git a/tests/e2e/standalone/run_test.go b/tests/e2e/standalone/run_test.go index 8550bf1d9..b1f149f73 100644 --- a/tests/e2e/standalone/run_test.go +++ b/tests/e2e/standalone/run_test.go @@ -125,7 +125,7 @@ func TestStandaloneRun(t *testing.T) { assert.Contains(t, output, "Exited Dapr successfully") }) - t.Run(fmt.Sprintf("check run with resources-path flag"), func(t *testing.T) { + t.Run("check run with nonexistent resources-path", func(t *testing.T) { args := []string{ "--app-id", "testapp", "--resources-path", "../testdata/nonexistentdir", @@ -133,17 +133,16 @@ func TestStandaloneRun(t *testing.T) { } output, err := cmdRun("", args...) t.Log(output) - require.NoError(t, err, "run failed") - assert.Contains(t, output, "failed to load components: open ../testdata/nonexistentdir:") - assert.Contains(t, output, "Exited App successfully") - assert.Contains(t, output, "Exited Dapr successfully") + require.Error(t, err, "run did not fail") + }) - args = []string{ + t.Run("check run with resources-path", func(t *testing.T) { + args := []string{ "--app-id", "testapp", "--resources-path", "../testdata/resources", "--", "bash", "-c", "echo 'test'", } - output, err = cmdRun("", args...) + output, err := cmdRun("", args...) t.Log(output) require.NoError(t, err, "run failed") assert.Contains(t, output, "component loaded. name: test-statestore, type: state.in-memory/v1")