From d07963655537e2081e123001b5379cb8c607002f Mon Sep 17 00:00:00 2001 From: Eduardo Asafe Date: Mon, 1 Apr 2024 04:26:51 -0300 Subject: [PATCH 1/4] Multiple accounts for deployment Add support for several --account flags. The accounts passed must already have been logged in. `force import` uses a mutex to synchonize concurrent deployments. --- command/deploy.go | 93 +++++++++++++++++++++++++++++------------------ command/import.go | 37 ++++++++++++++++--- command/logout.go | 2 +- command/root.go | 76 ++++++++++++++++++++++++++++++++------ lib/metadata.go | 3 +- 5 files changed, 155 insertions(+), 56 deletions(-) diff --git a/command/deploy.go b/command/deploy.go index b7a6c6e6..af9e6d15 100644 --- a/command/deploy.go +++ b/command/deploy.go @@ -6,10 +6,10 @@ import ( "os" "os/signal" "strings" + "sync" "syscall" "time" - . "github.com/ForceCLI/force/error" . "github.com/ForceCLI/force/lib" "github.com/spf13/cobra" ) @@ -38,53 +38,41 @@ func defaultDeployOutputOptions() *deployOutputOptions { var testFailureError = errors.New("Apex tests failed") -func monitorDeploy(deployId string) (ForceCheckDeploymentStatusResult, error) { - var result ForceCheckDeploymentStatusResult - var err error - retrying := false - for { - result, err = force.Metadata.CheckDeployStatus(deployId) - if err != nil { - if retrying { - return result, fmt.Errorf("Error getting deploy status: %w", err) - } else { - retrying = true - Log.Info(fmt.Sprintf("Received error checking deploy status: %s. Will retry once before aborting.", err.Error())) - } - } else { - retrying = false - } - if result.Done { - break - } - if !retrying { - Log.Info(result) - } - time.Sleep(5000 * time.Millisecond) - } - return result, err +type deployStatus struct { + mu sync.Mutex + aborted bool +} + +func (c *deployStatus) abort() { + c.mu.Lock() + c.aborted = true + c.mu.Unlock() +} + +func (c *deployStatus) isAborted() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.aborted } func deploy(force *Force, files ForceMetadataFiles, deployOptions *ForceDeployOptions, outputOptions *deployOutputOptions) error { - if outputOptions.quiet { - previousLogger := Log - var l quietLogger - Log = l - defer func() { - Log = previousLogger - }() - } + status := deployStatus{aborted: false} + + return deployWith(force, &status, files, deployOptions, outputOptions) +} + +func deployWith(force *Force, status *deployStatus, files ForceMetadataFiles, deployOptions *ForceDeployOptions, outputOptions *deployOutputOptions) error { startTime := time.Now() deployId, err := force.Metadata.StartDeploy(files, *deployOptions) if err != nil { - ErrorAndExit(err.Error()) + return err } stopDeployUponSignal(force, deployId) if outputOptions.interactive { watchDeploy(deployId) return nil } - result, err := monitorDeploy(deployId) + result, err := monitorDeploy(force, deployId, status) if err != nil { return err } @@ -156,6 +144,39 @@ func deploy(force *Force, files ForceMetadataFiles, deployOptions *ForceDeployOp return nil } +func monitorDeploy(force *Force, deployId string, status *deployStatus) (ForceCheckDeploymentStatusResult, error) { + var result ForceCheckDeploymentStatusResult + var err error + retrying := false + for { + if status.isAborted() { + fmt.Fprintf(os.Stderr, "Cancelling deploy %s\n", deployId) + force.Metadata.CancelDeploy(deployId) + return result, nil + } + result, err = force.Metadata.CheckDeployStatus(deployId) + if err != nil { + if retrying { + return result, fmt.Errorf("Error getting deploy status: %w", err) + } else { + retrying = true + Log.Info(fmt.Sprintf("Received error checking deploy status: %s. Will retry once before aborting.", err.Error())) + } + } else { + retrying = false + } + result.UserName = force.GetCredentials().UserInfo.UserName + if result.Done { + break + } + if !retrying { + Log.Info(result) + } + time.Sleep(5000 * time.Millisecond) + } + return result, err +} + func stopDeployUponSignal(force *Force, deployId string) { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) diff --git a/command/import.go b/command/import.go index 1090f266..1ca0944b 100644 --- a/command/import.go +++ b/command/import.go @@ -8,6 +8,7 @@ import ( "os/user" "path/filepath" "strings" + "sync" . "github.com/ForceCLI/force/error" . "github.com/ForceCLI/force/lib" @@ -92,6 +93,14 @@ func sourceDir(cmd *cobra.Command) string { } func runImport(root string, options ForceDeployOptions, displayOptions *deployOutputOptions) { + if displayOptions.quiet { + previousLogger := Log + var l quietLogger + Log = l + defer func() { + Log = previousLogger + }() + } files := make(ForceMetadataFiles) if _, err := os.Stat(filepath.Join(root, "package.xml")); os.IsNotExist(err) { ErrorAndExit(" \n" + filepath.Join(root, "package.xml") + "\ndoes not exist") @@ -113,11 +122,27 @@ func runImport(root string, options ForceDeployOptions, displayOptions *deployOu ErrorAndExit(err.Error()) } - err = deploy(force, files, &options, displayOptions) - if err == nil && displayOptions.reportFormat == "text" && !displayOptions.quiet { - fmt.Printf("Imported from %s\n", root) - } - if err != nil && (!errors.Is(err, testFailureError) || displayOptions.errorOnTestFailure) { - ErrorAndExit(err.Error()) + var deployments sync.WaitGroup + status := deployStatus{aborted: false} + + for _, f := range manager.getAllForce() { + if status.isAborted() { + break + } + current := f + deployments.Add(1) + go func() { + defer deployments.Done() + err := deployWith(current, &status, files, &options, displayOptions) + if err == nil && displayOptions.reportFormat == "text" && !displayOptions.quiet { + fmt.Printf("Imported from %s\n", root) + } + if err != nil && (!errors.Is(err, testFailureError) || displayOptions.errorOnTestFailure) && !status.isAborted() { + fmt.Fprintf(os.Stderr, "Aborting deploy due to %s\n", err.Error()) + status.abort() + } + }() } + + deployments.Wait() } diff --git a/command/logout.go b/command/logout.go index 525e0980..72572e95 100644 --- a/command/logout.go +++ b/command/logout.go @@ -37,7 +37,7 @@ func runLogout() { SetActiveLoginDefault() } if runtime.GOOS == "windows" { - cmd := exec.Command("title", account) + cmd := exec.Command("title", username) cmd.Run() } else { title := fmt.Sprintf("\033];%s\007", "") diff --git a/command/root.go b/command/root.go index 6e2621b0..6a54636b 100644 --- a/command/root.go +++ b/command/root.go @@ -14,11 +14,12 @@ import ( ) var ( - account string + accounts []string configName string _apiVersion string - force *Force + manager forceManager + force *Force ) func init() { @@ -30,7 +31,7 @@ func init() { } } RootCmd.SetArgs(args) - RootCmd.PersistentFlags().StringVarP(&account, "account", "a", "", "account `username` to use") + RootCmd.PersistentFlags().StringArrayVarP(&accounts, "account", "a", []string{}, "account `username` to use") RootCmd.PersistentFlags().StringVar(&configName, "config", "", "config directory to use (default: .force)") RootCmd.PersistentFlags().StringVarP(&_apiVersion, "apiversion", "V", "", "API version to use") } @@ -75,21 +76,16 @@ func envSession() *Force { } func initializeSession() { - var err error - if account != "" { - force, err = GetForce(account) - } else if force = envSession(); force == nil { - force, err = ActiveForce() - } - if err != nil { - ErrorAndExit(err.Error()) - } + manager = newForceManager(accounts) + if _apiVersion != "" { err := SetApiVersion(_apiVersion) if err != nil { ErrorAndExit(err.Error()) } } + + force = manager.getCurrentForce() } func Execute() { @@ -103,3 +99,59 @@ type quietLogger struct{} func (l quietLogger) Info(args ...interface{}) { } + +// provides support for commands that can be run concurrently for many accounts +type forceManager struct { + connections map[string]*Force + currentAccount string +} + +func (manager forceManager) getCurrentForce() *Force { + return manager.connections[manager.currentAccount] +} + +func (manager forceManager) getAllForce() []*Force { + fs := make([]*Force, 0, len(manager.connections)) + + for _, v := range manager.connections { + fs = append(fs, v) + } + return fs +} + +func newForceManager(accounts []string) forceManager { + var err error + fm := forceManager{connections: make(map[string]*Force, 1)} + + if len(accounts) > 1 { + for _, a := range accounts { + var f *Force + + f, err = GetForce(a) + if err != nil { + ErrorAndExit(err.Error()) + } + + fm.connections[a] = f + } + + fm.currentAccount = accounts[0] + } else { + var f *Force + + if len(accounts) == 1 { + f, err = GetForce(accounts[0]) + } else if f = envSession(); f == nil { + f, err = ActiveForce() + } + + if err != nil { + ErrorAndExit(err.Error()) + } + + fm.currentAccount = f.GetCredentials().UserInfo.UserName + fm.connections[fm.currentAccount] = f + } + + return fm +} diff --git a/lib/metadata.go b/lib/metadata.go index c58da314..f174a9b5 100644 --- a/lib/metadata.go +++ b/lib/metadata.go @@ -174,6 +174,7 @@ type ComponentDetails struct { } type ForceCheckDeploymentStatusResult struct { + UserName string CheckOnly bool `xml:"checkOnly"` CompletedDate time.Time `xml:"completedDate"` CreatedDate time.Time `xml:"createdDate"` @@ -745,7 +746,7 @@ func (results ForceCheckDeploymentStatusResult) String() string { complete = fmt.Sprintf(" (%d/%d)", results.NumberTestsCompleted, results.NumberTestsTotal) } - return fmt.Sprintf("Status: %s%s %s", results.Status, complete, results.StateDetail) + return fmt.Sprintf("Status (%s): %s%s %s", results.UserName, results.Status, complete, results.StateDetail) } func (fm *ForceMetadata) CancelDeploy(id string) (ForceCancelDeployResult, error) { From d2a562d6e08328356d5acbf68b556656e01a3a58 Mon Sep 17 00:00:00 2001 From: Eduardo Asafe Date: Sat, 23 Nov 2024 15:37:55 -0300 Subject: [PATCH 2/4] Error on interactive multiple accounts --- command/deploy.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/command/deploy.go b/command/deploy.go index af9e6d15..25ceb2fc 100644 --- a/command/deploy.go +++ b/command/deploy.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + . "github.com/ForceCLI/force/error" . "github.com/ForceCLI/force/lib" "github.com/spf13/cobra" ) @@ -212,6 +213,10 @@ func getDeploymentOutputOptions(cmd *cobra.Command) *deployOutputOptions { if interactive, err := cmd.Flags().GetBool("interactive"); err == nil { outputOptions.interactive = interactive + + if interactive && len(manager.connections) > 1 { + ErrorAndExit("interactive flag cannot be used with multiple accounts") + } } if ignoreCoverageWarnings, err := cmd.Flags().GetBool("ignorecoverage"); err == nil { From 3db7ef6afd1f76b3be485a4b36093bf3bb441dd3 Mon Sep 17 00:00:00 2001 From: Eduardo Asafe Date: Sun, 24 Nov 2024 07:50:04 -0300 Subject: [PATCH 3/4] Error on duplicate account --- command/root.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/command/root.go b/command/root.go index 6a54636b..fd6005a9 100644 --- a/command/root.go +++ b/command/root.go @@ -125,6 +125,10 @@ func newForceManager(accounts []string) forceManager { if len(accounts) > 1 { for _, a := range accounts { + if _, exists := fm.connections[a]; exists { + ErrorAndExit("Duplicate account: " + a) + } + var f *Force f, err = GetForce(a) From 5950467b7749a05be2307bf39deb29dc5236939b Mon Sep 17 00:00:00 2001 From: Eduardo Asafe Date: Sun, 27 Apr 2025 10:14:51 -0300 Subject: [PATCH 4/4] Error on unsupported accounts use --- command/root.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/command/root.go b/command/root.go index fd6005a9..e518577a 100644 --- a/command/root.go +++ b/command/root.go @@ -41,6 +41,7 @@ var RootCmd = &cobra.Command{ Short: "force CLI", PersistentPreRun: func(cmd *cobra.Command, args []string) { initializeConfig() + checkAccounts(cmd.Name()) switch cmd.Use { case "force", "login": default: @@ -61,6 +62,14 @@ func initializeConfig() { } } +func checkAccounts(command string) { + //currently only import allows many accounts so a catch all error handling is simpler + + if len(accounts) > 1 && command != "import" { + ErrorAndExit(fmt.Sprintf("Multiple accounts are not supported for %s yet", command)) + } +} + func envSession() *Force { token := os.Getenv("SF_ACCESS_TOKEN") instance := os.Getenv("SF_INSTANCE_URL")