From fe11b8e57eddc8768d9067e2cfaa714aa52319f9 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 19:36:02 +0400 Subject: [PATCH 01/18] Function for locally generating ETag --- cli/packages/util/helper.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 69a310efa..b758ebd9d 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" "math/rand" "os" @@ -298,3 +299,16 @@ func GenerateRandomString(length int) string { } return string(b) } + +func GenerateETagFromSecrets(secrets []models.SingleEnvironmentVariable) string { + sortedSecrets := SortSecretsByKeys(secrets) + content := []byte{} + + for _, secret := range sortedSecrets { + content = append(content, []byte(secret.Key)...) + content = append(content, []byte(secret.Value)...) + } + + hash := sha256.Sum256(content) + return fmt.Sprintf(`"%s"`, hex.EncodeToString(hash[:])) +} From f49c963367b4f7f165d2890b632c36496f31b320 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 19:36:38 +0400 Subject: [PATCH 02/18] Settings for hot reloading --- cli/packages/models/cli.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index c4bbd0175..31aeec67d 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -104,6 +104,15 @@ type GetAllSecretsParameters struct { Recursive bool } +type ExecuteCommandHotReloadParameters struct { + Enabled bool + GetSecretsDetails GetAllSecretsParameters + ProjectConfigDir string + SecretOverriding bool + ExpandSecrets bool + InitialETag string +} + type GetAllFoldersParameters struct { WorkspaceId string Environment string From 0dc4c92c89f0e061cab76bb247e4387e272b8998 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 19:37:11 +0400 Subject: [PATCH 03/18] Feat: --watch flag for watching for secret changes --- cli/packages/cmd/run.go | 413 +++++++++++++++++++++++++++++----------- 1 file changed, 303 insertions(+), 110 deletions(-) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 22a4ca65b..e3e276719 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -4,6 +4,7 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( + "context" "fmt" "os" "os/exec" @@ -11,6 +12,7 @@ import ( "runtime" "strings" "syscall" + "time" "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" @@ -77,6 +79,11 @@ var runCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + hotReloadEnabled, err := cmd.Flags().GetBool("watch") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + secretOverriding, err := cmd.Flags().GetBool("secret-overriding") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -116,68 +123,16 @@ var runCmd = &cobra.Command{ Recursive: recursive, } - if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { - request.InfisicalToken = token.Token - } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { - request.UniversalAuthAccessToken = token.Token - } - - secrets, err := util.GetAllEnvironmentVariables(request, projectConfigDir) - + env, initialETag, err := createInjectableEnvironment(request, projectConfigDir, secretOverriding, shouldExpandSecrets, token) if err != nil { util.HandleError(err, "Could not fetch secrets", "If you are using a service token to fetch secrets, please ensure it is valid") } - if secretOverriding { - secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_PERSONAL) - } else { - secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_SHARED) - } - - if shouldExpandSecrets { - - authParams := models.ExpandSecretsAuthentication{} - - if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { - authParams.InfisicalToken = token.Token - } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { - authParams.UniversalAuthAccessToken = token.Token - } - - secrets = util.ExpandSecrets(secrets, authParams, projectConfigDir) - } - - secretsByKey := getSecretsByKeys(secrets) - environmentVariables := make(map[string]string) - - // add all existing environment vars - for _, s := range os.Environ() { - kv := strings.SplitN(s, "=", 2) - key := kv[0] - value := kv[1] - environmentVariables[key] = value - } - - // check to see if there are any reserved key words in secrets to inject - filterReservedEnvVars(secretsByKey) - - // now add infisical secrets - for k, v := range secretsByKey { - environmentVariables[k] = v.Value - } - - // turn it back into a list of envs - var env []string - for key, value := range environmentVariables { - s := key + "=" + value - env = append(env, s) - } - log.Debug().Msgf("injecting the following environment variables into shell: %v", env) Telemetry.CaptureEvent("cli-command:run", posthog.NewProperties(). - Set("secretsCount", len(secrets)). + Set("secretsCount", len(env)). Set("environment", environmentName). Set("isUsingServiceToken", token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER). Set("isUsingUniversalAuthToken", token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER). @@ -185,21 +140,23 @@ var runCmd = &cobra.Command{ Set("multi-command", cmd.Flag("command").Value.String()). Set("version", util.CLI_VERSION)) + hotReloadParameters := models.ExecuteCommandHotReloadParameters{ + Enabled: hotReloadEnabled, + GetSecretsDetails: request, + ProjectConfigDir: projectConfigDir, + SecretOverriding: secretOverriding, + ExpandSecrets: shouldExpandSecrets, + InitialETag: initialETag, + } + if cmd.Flags().Changed("command") { command := cmd.Flag("command").Value.String() - err = executeMultipleCommandWithEnvs(command, len(secretsByKey), env) - if err != nil { - fmt.Println(err) - os.Exit(1) - } + executeMultipleCommandWithEnvs(command, len(env), env, hotReloadParameters, token) } else { - err = executeSingleCommandWithEnvs(args, len(secretsByKey), env) - if err != nil { - fmt.Println(err) - os.Exit(1) - } + executeSingleCommandWithEnvs(args, len(env), env, hotReloadParameters, token) + } }, } @@ -244,6 +201,7 @@ func init() { runCmd.Flags().Bool("include-imports", true, "Import linked secrets ") runCmd.Flags().Bool("recursive", false, "Fetch secrets from all sub-folders") runCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") + runCmd.Flags().Bool("watch", false, "Enable reload of application when secrets change") runCmd.Flags().StringP("command", "c", "", "chained commands to execute (e.g. \"npm install && npm run dev; echo ...\")") runCmd.Flags().StringP("tags", "t", "", "filter secrets by tag slugs ") runCmd.Flags().String("path", "/", "get secrets within a folder path") @@ -251,66 +209,301 @@ func init() { } // Will execute a single command and pass in the given secrets into the process -func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string) error { - command := args[0] - argsForCommand := args[1:] +func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string, reloadParameters models.ExecuteCommandHotReloadParameters, token *models.TokenDetails) { + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() - log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", secretsCount)) + // Set up signal handling + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - cmd := exec.Command(command, argsForCommand...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = env + if reloadParameters.Enabled { + log.Info().Msgf(color.YellowString("[HOT RELOAD] Watching for secret changes...")) + go func() { + <-sigChan + log.Info().Msg("Received termination signal. Cleaning up...") + cancelCtx() + }() + } - return execCmd(cmd) -} + var cmd *exec.Cmd -func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env []string) error { - shell := [2]string{"sh", "-c"} - if runtime.GOOS == "windows" { - shell = [2]string{"cmd", "/C"} - } else { - currentShell := os.Getenv("SHELL") - if currentShell != "" { - shell[0] = currentShell + startCmd := func() error { + command := args[0] + argsForCommand := args[1:] + + log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", secretsCount)) + + cmd := exec.Command(command, argsForCommand...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = env + + if reloadParameters.Enabled { + go func() { + execCommandWithReload(cmd, cancelCtx) + }() + } else { + return execCmd(cmd) } + return nil } - cmd := exec.Command(shell[0], shell[1], fullCommand) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = env - - log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", secretsCount)) - log.Debug().Msgf("executing command: %s %s %s \n", shell[0], shell[1], fullCommand) - - return execCmd(cmd) -} - -// Credit: inspired by AWS Valut -func execCmd(cmd *exec.Cmd) error { - sigChannel := make(chan os.Signal, 1) - signal.Notify(sigChannel) - - if err := cmd.Start(); err != nil { - return err + err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. + if err != nil { + util.HandleError(err, "Failed to start command") } - go func() { + // This part is only relevant when the --watch flag is passed, as it's purpose is to solely watch for changes and manage process reloads. + if reloadParameters.Enabled { + ticker := time.NewTicker(10 * time.Second) // We check every 10 seconds for secret changes + defer ticker.Stop() + for { - sig := <-sigChannel - _ = cmd.Process.Signal(sig) // process all sigs + select { + + case <-ctx.Done(): + log.Debug().Msg("Exiting hot reload...") + handleCommandTermination(cmd, cancelCtx) + return + case <-ticker.C: + log.Debug().Msg("Checking for environment updates...") + newEnv, newEtag, err := createInjectableEnvironment( + reloadParameters.GetSecretsDetails, + reloadParameters.ProjectConfigDir, + reloadParameters.SecretOverriding, + reloadParameters.ExpandSecrets, + token, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to fetch new secrets") + continue + } + + if newEtag != reloadParameters.InitialETag { + log.Info().Msg("[HOT RELOAD] Environment changed. Reloading application...") + reloadParameters.InitialETag = newEtag + env = newEnv + secretsCount = len(newEnv) + startCmd() // Restart the command with new environment + } else { + log.Debug().Msg("Not reloading because environments are identical") + } + } } - }() + } +} +func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env []string, reloadParameters models.ExecuteCommandHotReloadParameters, token *models.TokenDetails) { + ctx, cancelCtx := context.WithCancel(context.Background()) + defer cancelCtx() + + // Set up signal handling + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + if reloadParameters.Enabled { + log.Info().Msgf(color.HiMagentaString("[HOT RELOAD] Watching for secret changes...")) + go func() { + <-sigChan + log.Info().Msg(color.HiMagentaString("Received termination signal. Cleaning up...")) + cancelCtx() + }() + } + + var cmd *exec.Cmd + + startCmd := func() error { + shell := [2]string{"sh", "-c"} + if runtime.GOOS == "windows" { + shell = [2]string{"cmd", "/C"} + } else { + currentShell := os.Getenv("SHELL") + if currentShell != "" { + shell[0] = currentShell + } + } + + cmd = exec.CommandContext(ctx, shell[0], shell[1], fullCommand) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = env + + log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", secretsCount)) + log.Debug().Msgf("executing command: %s %s %s \n", shell[0], shell[1], fullCommand) + + if reloadParameters.Enabled { + go func() { + execCommandWithReload(cmd, cancelCtx) + }() + } else { + return execCmd(cmd) + } + return nil + } + + err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. + if err != nil { + util.HandleError(err, "Failed to start command") + } + + // This part is only relevant when the --watch flag is passed, as it's purpose is to solely watch for changes and manage process reloads. + if reloadParameters.Enabled { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Info().Msg(color.HiMagentaString("[HOT RELOAD] Exiting...")) + handleCommandTermination(cmd, cancelCtx) + return + case <-ticker.C: + log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] | Checking for environment updates...")) + newEnv, newEtag, err := createInjectableEnvironment( + reloadParameters.GetSecretsDetails, + reloadParameters.ProjectConfigDir, + reloadParameters.SecretOverriding, + reloadParameters.ExpandSecrets, + token, + ) + if err != nil { + log.Error().Err(err).Msg("[HOT RELOAD] | Failed to fetch new secrets") + continue + } + + if newEtag != reloadParameters.InitialETag { + log.Info().Msg("[HOT RELOAD] Environment changed. Reloading application...") + reloadParameters.InitialETag = newEtag + env = newEnv + secretsCount = len(newEnv) + startCmd() // Restart the command with new environment + } else { + log.Debug().Msg("Not reloading because environments are identical") + } + } + } + } +} + +func execCmd(cmd *exec.Cmd) error { + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start command: %v", err) + } if err := cmd.Wait(); err != nil { - _ = cmd.Process.Signal(os.Kill) - return fmt.Errorf("failed to wait for command termination: %v", err) + return err // Return the raw error for more detailed handling in the caller } - waitStatus := cmd.ProcessState.Sys().(syscall.WaitStatus) - os.Exit(waitStatus.ExitStatus()) return nil } + +func execCommandWithReload(cmd *exec.Cmd, cancel context.CancelFunc) { + err := execCmd(cmd) + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == -1 { + // This is hit when the command exits due to a reload signal. + log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] Process was terminated as part of reload, this is expected behavior")) + } else { + // This is hit when the command exits with an unexpected exit code. + // This should stop the reload logic and exit the CLI. + log.Error().Err(err).Msgf("[HOT RELOAD] Command execution failed with exit code: %d", exitErr.ExitCode()) + + // ? Question: If the command throws an error, then the infisical CLI should terminate as well, right? + cancel() + util.PrintErrorAndExit(exitErr.ExitCode(), err, "[HOT RELOAD] Failed to start command") + } + } else { + // This is hit due to generic errors, not exit errors. This is a catch-all for any other errors. + cancel() + util.HandleError(err, "[HOT RELOAD] Command execution failed") + } + } else { + // If the command exits, the CLI should terminate as well + log.Debug().Msg(color.HiMagentaString("Command exited without faults")) + cancel() + return + } +} + +func handleCommandTermination(cmd *exec.Cmd, cmdCancel context.CancelFunc) { + + { + if cmd != nil && cmd.Process != nil { + log.Info().Msg(color.HiMagentaString("[HOT RELOAD] Terminating existing process...")) + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + log.Error().Err(err).Msg("[HOT RELOAD] Failed to terminate process") + if err := cmd.Process.Kill(); err != nil { + log.Error().Err(err).Msg("[HOT RELOAD] Failed to kill process") + } + } + if cmdCancel != nil { + cmdCancel() + } + // Wait for the process to finish + _, err := cmd.Process.Wait() + if err != nil { + if err.Error() != "wait: no child processes" { + log.Error().Err(err).Msg("[HOT RELOAD] Error waiting for process to terminate") + } + } + } + } +} + +func createInjectableEnvironment(request models.GetAllSecretsParameters, projectConfigDir string, secretOverriding bool, shouldExpandSecrets bool, token *models.TokenDetails) ([]string, string, error) { + + secrets, err := util.GetAllEnvironmentVariables(request, projectConfigDir) + + if err != nil { + return nil, "", err + } + + if secretOverriding { + secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_PERSONAL) + } else { + secrets = util.OverrideSecrets(secrets, util.SECRET_TYPE_SHARED) + } + + if shouldExpandSecrets { + + authParams := models.ExpandSecretsAuthentication{} + + if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER { + authParams.InfisicalToken = token.Token + } else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + authParams.UniversalAuthAccessToken = token.Token + } + + secrets = util.ExpandSecrets(secrets, authParams, projectConfigDir) + } + + secretsByKey := getSecretsByKeys(secrets) + environmentVariables := make(map[string]string) + + // add all existing environment vars + for _, s := range os.Environ() { + kv := strings.SplitN(s, "=", 2) + key := kv[0] + value := kv[1] + environmentVariables[key] = value + } + + // check to see if there are any reserved key words in secrets to inject + filterReservedEnvVars(secretsByKey) + + // now add infisical secrets + for k, v := range secretsByKey { + environmentVariables[k] = v.Value + } + + // Create and sort the env slice using slices.SortFunc + env := make([]string, 0, len(environmentVariables)) + for key, value := range environmentVariables { + env = append(env, key+"="+value) + } + + return env, util.GenerateETagFromSecrets(secrets), nil +} From d4aab66da258ecf67f9841337db585972ae4ffd9 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 19:45:00 +0400 Subject: [PATCH 04/18] Update test-TestUniversalAuth_SecretsGetWrongEnvironment --- .../test-TestUniversalAuth_SecretsGetWrongEnvironment | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment index c3811bd22..bf1a3be9f 100644 --- a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment +++ b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment @@ -1,4 +1,4 @@ -error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=500] [response={"statusCode":500,"error":"Internal Server Error","message":"'invalid-env' environment not found in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2"}] +error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&include_imports=true&recursive=true&secretPath=%2F&workspaceId=***] [status-code=500] [response={"statusCode":500,"error":"Internal Server Error","message":"'invalid-env' environment not found in project with ID ***"}] If this issue continues, get support at https://infisical.com/slack From 47cba8ec3ccbaa5231ebe93604f51da29862c284 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 19:48:20 +0400 Subject: [PATCH 05/18] Update test-TestUniversalAuth_SecretsGetWrongEnvironment --- .../test-TestUniversalAuth_SecretsGetWrongEnvironment | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment index bf1a3be9f..c3811bd22 100644 --- a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment +++ b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment @@ -1,4 +1,4 @@ -error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&include_imports=true&recursive=true&secretPath=%2F&workspaceId=***] [status-code=500] [response={"statusCode":500,"error":"Internal Server Error","message":"'invalid-env' environment not found in project with ID ***"}] +error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=500] [response={"statusCode":500,"error":"Internal Server Error","message":"'invalid-env' environment not found in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2"}] If this issue continues, get support at https://infisical.com/slack From d45ac660645f959b79ba76454edc06c8852989c4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 20:03:01 +0400 Subject: [PATCH 06/18] Fix: Match test cases --- cli/packages/cmd/run.go | 44 ++++++++++++++++++++------------------ cli/packages/models/cli.go | 8 ++++++- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index e3e276719..28617f572 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -123,16 +123,16 @@ var runCmd = &cobra.Command{ Recursive: recursive, } - env, initialETag, err := createInjectableEnvironment(request, projectConfigDir, secretOverriding, shouldExpandSecrets, token) + injectableEnvironment, err := createInjectableEnvironment(request, projectConfigDir, secretOverriding, shouldExpandSecrets, token) if err != nil { util.HandleError(err, "Could not fetch secrets", "If you are using a service token to fetch secrets, please ensure it is valid") } - log.Debug().Msgf("injecting the following environment variables into shell: %v", env) + log.Debug().Msgf("injecting the following environment variables into shell: %v", injectableEnvironment.Variables) Telemetry.CaptureEvent("cli-command:run", posthog.NewProperties(). - Set("secretsCount", len(env)). + Set("secretsCount", injectableEnvironment.SecretsCount). Set("environment", environmentName). Set("isUsingServiceToken", token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER). Set("isUsingUniversalAuthToken", token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER). @@ -146,16 +146,14 @@ var runCmd = &cobra.Command{ ProjectConfigDir: projectConfigDir, SecretOverriding: secretOverriding, ExpandSecrets: shouldExpandSecrets, - InitialETag: initialETag, + CurrentETag: injectableEnvironment.ETag, } if cmd.Flags().Changed("command") { command := cmd.Flag("command").Value.String() - - executeMultipleCommandWithEnvs(command, len(env), env, hotReloadParameters, token) - + executeMultipleCommandWithEnvs(command, injectableEnvironment.SecretsCount, injectableEnvironment.Variables, hotReloadParameters, token) } else { - executeSingleCommandWithEnvs(args, len(env), env, hotReloadParameters, token) + executeSingleCommandWithEnvs(args, injectableEnvironment.SecretsCount, injectableEnvironment.Variables, hotReloadParameters, token) } }, @@ -269,7 +267,7 @@ func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string, return case <-ticker.C: log.Debug().Msg("Checking for environment updates...") - newEnv, newEtag, err := createInjectableEnvironment( + injectableEnvironment, err := createInjectableEnvironment( reloadParameters.GetSecretsDetails, reloadParameters.ProjectConfigDir, reloadParameters.SecretOverriding, @@ -281,11 +279,11 @@ func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string, continue } - if newEtag != reloadParameters.InitialETag { + if injectableEnvironment.ETag != reloadParameters.CurrentETag { log.Info().Msg("[HOT RELOAD] Environment changed. Reloading application...") - reloadParameters.InitialETag = newEtag - env = newEnv - secretsCount = len(newEnv) + reloadParameters.CurrentETag = injectableEnvironment.ETag + env = injectableEnvironment.Variables + secretsCount = injectableEnvironment.SecretsCount startCmd() // Restart the command with new environment } else { log.Debug().Msg("Not reloading because environments are identical") @@ -361,7 +359,7 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env [] return case <-ticker.C: log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] | Checking for environment updates...")) - newEnv, newEtag, err := createInjectableEnvironment( + injectableEnvironment, err := createInjectableEnvironment( reloadParameters.GetSecretsDetails, reloadParameters.ProjectConfigDir, reloadParameters.SecretOverriding, @@ -373,11 +371,11 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env [] continue } - if newEtag != reloadParameters.InitialETag { + if injectableEnvironment.ETag != reloadParameters.CurrentETag { log.Info().Msg("[HOT RELOAD] Environment changed. Reloading application...") - reloadParameters.InitialETag = newEtag - env = newEnv - secretsCount = len(newEnv) + reloadParameters.CurrentETag = injectableEnvironment.ETag + env = injectableEnvironment.Variables + secretsCount = injectableEnvironment.SecretsCount startCmd() // Restart the command with new environment } else { log.Debug().Msg("Not reloading because environments are identical") @@ -453,12 +451,12 @@ func handleCommandTermination(cmd *exec.Cmd, cmdCancel context.CancelFunc) { } } -func createInjectableEnvironment(request models.GetAllSecretsParameters, projectConfigDir string, secretOverriding bool, shouldExpandSecrets bool, token *models.TokenDetails) ([]string, string, error) { +func createInjectableEnvironment(request models.GetAllSecretsParameters, projectConfigDir string, secretOverriding bool, shouldExpandSecrets bool, token *models.TokenDetails) (models.InjectableEnvironmentResult, error) { secrets, err := util.GetAllEnvironmentVariables(request, projectConfigDir) if err != nil { - return nil, "", err + return models.InjectableEnvironmentResult{}, err } if secretOverriding { @@ -505,5 +503,9 @@ func createInjectableEnvironment(request models.GetAllSecretsParameters, project env = append(env, key+"="+value) } - return env, util.GenerateETagFromSecrets(secrets), nil + return models.InjectableEnvironmentResult{ + Variables: env, + ETag: util.GenerateETagFromSecrets(secrets), + SecretsCount: len(secretsByKey), + }, nil } diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 31aeec67d..d56517c6f 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -104,13 +104,19 @@ type GetAllSecretsParameters struct { Recursive bool } +type InjectableEnvironmentResult struct { + Variables []string + ETag string + SecretsCount int +} + type ExecuteCommandHotReloadParameters struct { Enabled bool GetSecretsDetails GetAllSecretsParameters ProjectConfigDir string SecretOverriding bool ExpandSecrets bool - InitialETag string + CurrentETag string } type GetAllFoldersParameters struct { From 5fd975b1d75a08d8be0b5a604aecfeeeea5f2d38 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 20:09:40 +0400 Subject: [PATCH 07/18] Fix: Console error on manual cancel when not using hot reload --- cli/packages/cmd/run.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 28617f572..89dd04592 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -22,6 +22,8 @@ import ( "github.com/spf13/cobra" ) +var INTERRUPT_ERR = fmt.Errorf("signal: interrupt") + // runCmd represents the run command var runCmd = &cobra.Command{ Example: ` @@ -250,6 +252,10 @@ func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string, err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. if err != nil { + if err.Error() == INTERRUPT_ERR.Error() { + log.Debug().Msg(color.HiMagentaString("Process was terminated manually by the user")) + os.Exit(1) + } util.HandleError(err, "Failed to start command") } @@ -343,6 +349,10 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env [] err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. if err != nil { + if err.Error() == INTERRUPT_ERR.Error() { + log.Debug().Msg(color.HiMagentaString("Process was terminated manually by the user")) + os.Exit(1) + } util.HandleError(err, "Failed to start command") } From 632900e51693bee87583245ed36a16c9b0b8c763 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 20:10:00 +0400 Subject: [PATCH 08/18] Update run.go --- cli/packages/cmd/run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 89dd04592..eb563b118 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -350,7 +350,7 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env [] err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. if err != nil { if err.Error() == INTERRUPT_ERR.Error() { - log.Debug().Msg(color.HiMagentaString("Process was terminated manually by the user")) + log.Debug().Msg(("Process was terminated manually by the user")) os.Exit(1) } util.HandleError(err, "Failed to start command") From 2c7e342b189c321f3e063d329946474916ab8c00 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 20:10:11 +0400 Subject: [PATCH 09/18] Update run.go --- cli/packages/cmd/run.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index eb563b118..cc52ce453 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -253,7 +253,7 @@ func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string, err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. if err != nil { if err.Error() == INTERRUPT_ERR.Error() { - log.Debug().Msg(color.HiMagentaString("Process was terminated manually by the user")) + log.Debug().Msg(("Process was terminated manually by the user")) os.Exit(1) } util.HandleError(err, "Failed to start command") From 68f768749b390078ea85828143394bc899b625b9 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 20:10:50 +0400 Subject: [PATCH 10/18] Update run.go --- cli/packages/cmd/run.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index cc52ce453..bec44ba6c 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -22,7 +22,7 @@ import ( "github.com/spf13/cobra" ) -var INTERRUPT_ERR = fmt.Errorf("signal: interrupt") +var ManualInterruptErr = fmt.Errorf("signal: interrupt") // runCmd represents the run command var runCmd = &cobra.Command{ @@ -252,7 +252,7 @@ func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string, err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. if err != nil { - if err.Error() == INTERRUPT_ERR.Error() { + if err.Error() == ManualInterruptErr.Error() { log.Debug().Msg(("Process was terminated manually by the user")) os.Exit(1) } @@ -349,7 +349,7 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env [] err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. if err != nil { - if err.Error() == INTERRUPT_ERR.Error() { + if err.Error() == ManualInterruptErr.Error() { log.Debug().Msg(("Process was terminated manually by the user")) os.Exit(1) } From 5c7cec0c817c69cb161c8a651c941b39f5620469 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 20:11:27 +0400 Subject: [PATCH 11/18] Update run.go --- cli/packages/cmd/run.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index bec44ba6c..5022d3245 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -22,7 +22,7 @@ import ( "github.com/spf13/cobra" ) -var ManualInterruptErr = fmt.Errorf("signal: interrupt") +var ErrManualInterrupt = fmt.Errorf("signal: interrupt") // runCmd represents the run command var runCmd = &cobra.Command{ @@ -252,7 +252,7 @@ func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string, err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. if err != nil { - if err.Error() == ManualInterruptErr.Error() { + if err.Error() == ErrManualInterrupt.Error() { log.Debug().Msg(("Process was terminated manually by the user")) os.Exit(1) } @@ -349,7 +349,7 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env [] err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. if err != nil { - if err.Error() == ManualInterruptErr.Error() { + if err.Error() == ErrManualInterrupt.Error() { log.Debug().Msg(("Process was terminated manually by the user")) os.Exit(1) } From 85590af99e61322b48b59d36b051296c3c3a83d4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 21:44:32 +0400 Subject: [PATCH 12/18] Fix: Removed more duplicate code and started using process groups to fix memory leak --- cli/packages/cmd/run.go | 277 +++++++++++++++++++--------------------- 1 file changed, 128 insertions(+), 149 deletions(-) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 5022d3245..b7b67e459 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -218,86 +218,51 @@ func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string, signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) if reloadParameters.Enabled { - log.Info().Msgf(color.YellowString("[HOT RELOAD] Watching for secret changes...")) - go func() { - <-sigChan - log.Info().Msg("Received termination signal. Cleaning up...") - cancelCtx() - }() + handleHotReloadCleanup(sigChan, cancelCtx) } - var cmd *exec.Cmd + var currentCmd *exec.Cmd startCmd := func() error { + if currentCmd != nil { + terminateProcessGroup(currentCmd) + } + command := args[0] argsForCommand := args[1:] log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", secretsCount)) - cmd := exec.Command(command, argsForCommand...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = env + currentCmd = exec.Command(command, argsForCommand...) + currentCmd.Stdin = os.Stdin + currentCmd.Stdout = os.Stdout + currentCmd.Stderr = os.Stderr + currentCmd.Env = env + currentCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if reloadParameters.Enabled { - go func() { - execCommandWithReload(cmd, cancelCtx) - }() + go runCommandWithReloading(currentCmd) } else { - return execCmd(cmd) + return currentCmd.Run() } return nil } - err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. + err := startCmd() // Initial command start if err != nil { if err.Error() == ErrManualInterrupt.Error() { - log.Debug().Msg(("Process was terminated manually by the user")) + log.Debug().Msg("Process was terminated manually by the user") os.Exit(1) } util.HandleError(err, "Failed to start command") } - // This part is only relevant when the --watch flag is passed, as it's purpose is to solely watch for changes and manage process reloads. + // This part is only relevant when the --watch flag is passed if reloadParameters.Enabled { - ticker := time.NewTicker(10 * time.Second) // We check every 10 seconds for secret changes - defer ticker.Stop() - - for { - select { - - case <-ctx.Done(): - log.Debug().Msg("Exiting hot reload...") - handleCommandTermination(cmd, cancelCtx) - return - case <-ticker.C: - log.Debug().Msg("Checking for environment updates...") - injectableEnvironment, err := createInjectableEnvironment( - reloadParameters.GetSecretsDetails, - reloadParameters.ProjectConfigDir, - reloadParameters.SecretOverriding, - reloadParameters.ExpandSecrets, - token, - ) - if err != nil { - log.Error().Err(err).Msg("Failed to fetch new secrets") - continue - } - - if injectableEnvironment.ETag != reloadParameters.CurrentETag { - log.Info().Msg("[HOT RELOAD] Environment changed. Reloading application...") - reloadParameters.CurrentETag = injectableEnvironment.ETag - env = injectableEnvironment.Variables - secretsCount = injectableEnvironment.SecretsCount - startCmd() // Restart the command with new environment - } else { - log.Debug().Msg("Not reloading because environments are identical") - } - } - } + runHotReloadLoop(ctx, &reloadParameters, token, ¤tCmd, &env, &secretsCount, startCmd) } } + func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env []string, reloadParameters models.ExecuteCommandHotReloadParameters, token *models.TokenDetails) { ctx, cancelCtx := context.WithCancel(context.Background()) defer cancelCtx() @@ -307,17 +272,16 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env [] signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) if reloadParameters.Enabled { - log.Info().Msgf(color.HiMagentaString("[HOT RELOAD] Watching for secret changes...")) - go func() { - <-sigChan - log.Info().Msg(color.HiMagentaString("Received termination signal. Cleaning up...")) - cancelCtx() - }() + handleHotReloadCleanup(sigChan, cancelCtx) } - var cmd *exec.Cmd + var currentCmd *exec.Cmd startCmd := func() error { + if currentCmd != nil { + terminateProcessGroup(currentCmd) + } + shell := [2]string{"sh", "-c"} if runtime.GOOS == "windows" { shell = [2]string{"cmd", "/C"} @@ -328,134 +292,149 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env [] } } - cmd = exec.CommandContext(ctx, shell[0], shell[1], fullCommand) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = env + currentCmd = exec.Command(shell[0], shell[1], fullCommand) + currentCmd.Stdin = os.Stdin + currentCmd.Stdout = os.Stdout + currentCmd.Stderr = os.Stderr + currentCmd.Env = env + currentCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", secretsCount)) log.Debug().Msgf("executing command: %s %s %s \n", shell[0], shell[1], fullCommand) if reloadParameters.Enabled { - go func() { - execCommandWithReload(cmd, cancelCtx) - }() + go runCommandWithReloading(currentCmd) } else { - return execCmd(cmd) + return currentCmd.Run() } return nil } - err := startCmd() // Initial command start, if no --watch flag is passed, it will work like in old versions of infisical CLI. + err := startCmd() // Initial command start if err != nil { if err.Error() == ErrManualInterrupt.Error() { - log.Debug().Msg(("Process was terminated manually by the user")) + log.Debug().Msg("Process was terminated manually by the user") os.Exit(1) } util.HandleError(err, "Failed to start command") } - // This part is only relevant when the --watch flag is passed, as it's purpose is to solely watch for changes and manage process reloads. + // This part is only relevant when the --watch flag is passed if reloadParameters.Enabled { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - log.Info().Msg(color.HiMagentaString("[HOT RELOAD] Exiting...")) - handleCommandTermination(cmd, cancelCtx) - return - case <-ticker.C: - log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] | Checking for environment updates...")) - injectableEnvironment, err := createInjectableEnvironment( - reloadParameters.GetSecretsDetails, - reloadParameters.ProjectConfigDir, - reloadParameters.SecretOverriding, - reloadParameters.ExpandSecrets, - token, - ) - if err != nil { - log.Error().Err(err).Msg("[HOT RELOAD] | Failed to fetch new secrets") - continue - } - - if injectableEnvironment.ETag != reloadParameters.CurrentETag { - log.Info().Msg("[HOT RELOAD] Environment changed. Reloading application...") - reloadParameters.CurrentETag = injectableEnvironment.ETag - env = injectableEnvironment.Variables - secretsCount = injectableEnvironment.SecretsCount - startCmd() // Restart the command with new environment - } else { - log.Debug().Msg("Not reloading because environments are identical") - } - } - } + runHotReloadLoop(ctx, &reloadParameters, token, ¤tCmd, &env, &secretsCount, startCmd) } } -func execCmd(cmd *exec.Cmd) error { - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start command: %v", err) - } - - if err := cmd.Wait(); err != nil { - return err // Return the raw error for more detailed handling in the caller - } - - return nil -} - -func execCommandWithReload(cmd *exec.Cmd, cancel context.CancelFunc) { - err := execCmd(cmd) +func runCommandWithReloading(cmd *exec.Cmd) { + err := cmd.Run() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { if exitErr.ExitCode() == -1 { - // This is hit when the command exits due to a reload signal. log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] Process was terminated as part of reload, this is expected behavior")) } else { - // This is hit when the command exits with an unexpected exit code. - // This should stop the reload logic and exit the CLI. log.Error().Err(err).Msgf("[HOT RELOAD] Command execution failed with exit code: %d", exitErr.ExitCode()) - - // ? Question: If the command throws an error, then the infisical CLI should terminate as well, right? - cancel() - util.PrintErrorAndExit(exitErr.ExitCode(), err, "[HOT RELOAD] Failed to start command") } } else { - // This is hit due to generic errors, not exit errors. This is a catch-all for any other errors. - cancel() - util.HandleError(err, "[HOT RELOAD] Command execution failed") + log.Error().Err(err).Msg("[HOT RELOAD] Command execution failed") } } else { - // If the command exits, the CLI should terminate as well log.Debug().Msg(color.HiMagentaString("Command exited without faults")) - cancel() - return } } -func handleCommandTermination(cmd *exec.Cmd, cmdCancel context.CancelFunc) { +func handleHotReloadCleanup(sigChan chan os.Signal, cancelCtx context.CancelFunc) { + log.Info().Msgf(color.YellowString("[HOT RELOAD] Watching for secret changes...")) + go func() { + <-sigChan + log.Info().Msg("Received termination signal. Cleaning up...") + cancelCtx() + }() +} - { - if cmd != nil && cmd.Process != nil { - log.Info().Msg(color.HiMagentaString("[HOT RELOAD] Terminating existing process...")) - if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { - log.Error().Err(err).Msg("[HOT RELOAD] Failed to terminate process") - if err := cmd.Process.Kill(); err != nil { - log.Error().Err(err).Msg("[HOT RELOAD] Failed to kill process") - } +func terminateProcessGroup(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + + log.Info().Msg(color.HiMagentaString("[HOT RELOAD] Terminating existing process group...")) + + pgid, err := syscall.Getpgid(cmd.Process.Pid) + if err == nil { + // Send SIGTERM to the process group + if err := syscall.Kill(-pgid, syscall.SIGTERM); err != nil { + log.Error().Err(err).Msg("[HOT RELOAD] Failed to terminate process group") + } + + // Wait for a short time to allow for graceful shutdown + time.Sleep(2 * time.Second) + + // If the process is still running, force kill the process group + if cmd.ProcessState == nil { + if err := syscall.Kill(-pgid, syscall.SIGKILL); err != nil { + log.Error().Err(err).Msg("[HOT RELOAD] Failed to kill process group") } - if cmdCancel != nil { - cmdCancel() + } + } else { + log.Error().Err(err).Msg("[HOT RELOAD] Failed to get process group ID") + } + + // Wait for the process to finish + _, err = cmd.Process.Wait() + if err != nil { + if err.Error() != "wait: no child processes" { + log.Error().Err(err).Msg("[HOT RELOAD] Error waiting for process to terminate") + } + } +} + +func runHotReloadLoop( + ctx context.Context, + reloadParameters *models.ExecuteCommandHotReloadParameters, + token *models.TokenDetails, + currentCmd **exec.Cmd, + env *[]string, + secretsCount *int, + startCmd func() error, +) { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Debug().Msg("Exiting hot reload...") + if *currentCmd != nil { + terminateProcessGroup(*currentCmd) } - // Wait for the process to finish - _, err := cmd.Process.Wait() + return + case <-ticker.C: + log.Debug().Msg("Checking for environment updates...") + injectableEnvironment, err := createInjectableEnvironment( + reloadParameters.GetSecretsDetails, + reloadParameters.ProjectConfigDir, + reloadParameters.SecretOverriding, + reloadParameters.ExpandSecrets, + token, + ) if err != nil { - if err.Error() != "wait: no child processes" { - log.Error().Err(err).Msg("[HOT RELOAD] Error waiting for process to terminate") + log.Error().Err(err).Msg("Failed to fetch new secrets") + continue + } + + if injectableEnvironment.ETag != reloadParameters.CurrentETag { + log.Info().Msg("[HOT RELOAD] Environment changed. Reloading application...") + reloadParameters.CurrentETag = injectableEnvironment.ETag + *env = injectableEnvironment.Variables + *secretsCount = injectableEnvironment.SecretsCount + + // Start a new process (this will also terminate the existing one if any) + err := startCmd() + if err != nil { + log.Error().Err(err).Msg("[HOT RELOAD] Failed to restart command") + continue } + } else { + log.Debug().Msg("Not reloading because environments are identical") } } } From 8eb234a12f2c9a81be7666e51c45f13d0cd07710 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 27 Aug 2024 21:58:53 +0400 Subject: [PATCH 13/18] Update run.go --- cli/packages/cmd/run.go | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index b7b67e459..9d0662bd1 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -238,12 +238,12 @@ func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string, currentCmd.Stdout = os.Stdout currentCmd.Stderr = os.Stderr currentCmd.Env = env - currentCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if reloadParameters.Enabled { + currentCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} go runCommandWithReloading(currentCmd) } else { - return currentCmd.Run() + return execCmd(currentCmd) } return nil } @@ -254,7 +254,7 @@ func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string, log.Debug().Msg("Process was terminated manually by the user") os.Exit(1) } - util.HandleError(err, "Failed to start command") + util.HandleError(err, "Failed to run command") } // This part is only relevant when the --watch flag is passed @@ -297,15 +297,15 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env [] currentCmd.Stdout = os.Stdout currentCmd.Stderr = os.Stderr currentCmd.Env = env - currentCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", secretsCount)) log.Debug().Msgf("executing command: %s %s %s \n", shell[0], shell[1], fullCommand) if reloadParameters.Enabled { + currentCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} go runCommandWithReloading(currentCmd) } else { - return currentCmd.Run() + return execCmd(currentCmd) } return nil } @@ -316,7 +316,7 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env [] log.Debug().Msg("Process was terminated manually by the user") os.Exit(1) } - util.HandleError(err, "Failed to start command") + util.HandleError(err, "Failed to run command") } // This part is only relevant when the --watch flag is passed @@ -325,6 +325,18 @@ func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env [] } } +func execCmd(cmd *exec.Cmd) error { + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start command: %v", err) + } + + if err := cmd.Wait(); err != nil { + return err // Return the raw error for more detailed handling in the caller + } + + return nil +} + func runCommandWithReloading(cmd *exec.Cmd) { err := cmd.Run() if err != nil { @@ -332,13 +344,14 @@ func runCommandWithReloading(cmd *exec.Cmd) { if exitErr.ExitCode() == -1 { log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] Process was terminated as part of reload, this is expected behavior")) } else { - log.Error().Err(err).Msgf("[HOT RELOAD] Command execution failed with exit code: %d", exitErr.ExitCode()) + util.HandleError(err, "Command execution failed") } } else { log.Error().Err(err).Msg("[HOT RELOAD] Command execution failed") } } else { - log.Debug().Msg(color.HiMagentaString("Command exited without faults")) + log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] Command exited without faults")) + os.Exit(0) } } From 2d780e0566491424349d4eb2ef81b0c5efa27e5f Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Wed, 28 Aug 2024 05:22:27 +0400 Subject: [PATCH 14/18] Feat: watch mode for run command --- cli/go.mod | 2 +- cli/go.sum | 2 + cli/packages/cmd/run.go | 452 +++++++++++++++----------------------- cli/packages/util/exec.go | 95 ++++++++ 4 files changed, 276 insertions(+), 275 deletions(-) create mode 100644 cli/packages/util/exec.go diff --git a/cli/go.mod b/cli/go.mod index bcde660ab..df902ee8d 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -12,7 +12,7 @@ require ( github.com/gitleaks/go-gitdiff v0.8.0 github.com/h2non/filetype v1.1.3 github.com/infisical/go-sdk v0.3.3 - github.com/mattn/go-isatty v0.0.18 + github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a github.com/muesli/mango-cobra v1.2.0 github.com/muesli/reflow v0.3.0 diff --git a/cli/go.sum b/cli/go.sum index d6f04aed0..157c9f4b8 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -297,6 +297,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 9d0662bd1..a415d7a27 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -4,13 +4,12 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( - "context" + "errors" "fmt" "os" "os/exec" - "os/signal" - "runtime" "strings" + "sync" "syscall" "time" @@ -22,7 +21,8 @@ import ( "github.com/spf13/cobra" ) -var ErrManualInterrupt = fmt.Errorf("signal: interrupt") +var ErrManualSignalInterrupt = errors.New("signal: interrupt") +var WaitGroup = new(sync.WaitGroup) // runCmd represents the run command var runCmd = &cobra.Command{ @@ -81,7 +81,11 @@ var runCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - hotReloadEnabled, err := cmd.Flags().GetBool("watch") + command, err := cmd.Flags().GetString("command") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + if err != nil { util.HandleError(err, "Unable to parse flag") } @@ -91,6 +95,11 @@ var runCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + watchMode, err := cmd.Flags().GetBool("watch") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + shouldExpandSecrets, err := cmd.Flags().GetBool("expand") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -142,39 +151,160 @@ var runCmd = &cobra.Command{ Set("multi-command", cmd.Flag("command").Value.String()). Set("version", util.CLI_VERSION)) - hotReloadParameters := models.ExecuteCommandHotReloadParameters{ - Enabled: hotReloadEnabled, - GetSecretsDetails: request, - ProjectConfigDir: projectConfigDir, - SecretOverriding: secretOverriding, - ExpandSecrets: shouldExpandSecrets, - CurrentETag: injectableEnvironment.ETag, - } + executeSpecifiedCommand(command, args, watchMode, request, projectConfigDir, shouldExpandSecrets, secretOverriding, token) - if cmd.Flags().Changed("command") { - command := cmd.Flag("command").Value.String() - executeMultipleCommandWithEnvs(command, injectableEnvironment.SecretsCount, injectableEnvironment.Variables, hotReloadParameters, token) - } else { - executeSingleCommandWithEnvs(args, injectableEnvironment.SecretsCount, injectableEnvironment.Variables, hotReloadParameters, token) - - } }, } -var ( - reservedEnvVars = []string{ - "HOME", "PATH", "PS1", "PS2", - "PWD", "EDITOR", "XAUTHORITY", "USER", - "TERM", "TERMINFO", "SHELL", "MAIL", +func executeSpecifiedCommand(commandFlag string, args []string, watchMode bool, request models.GetAllSecretsParameters, projectConfigDir string, expandSecrets bool, secretOverriding bool, token *models.TokenDetails) { + + var cmd *exec.Cmd + var err error + var lastSecretsFetch time.Time + var lastUpdateEvent time.Time + var watchMutex sync.Mutex + var processMutex sync.Mutex + var beingTerminated = false + var currentETag string + + startProcess := func(environment models.InjectableEnvironmentResult) { + currentETag = environment.ETag + secretsFetchedAt := time.Now() + if secretsFetchedAt.After(lastSecretsFetch) { + lastSecretsFetch = secretsFetchedAt + } + + shouldRestartProcess := cmd != nil + // terminate the old process before starting a new one + if shouldRestartProcess { + beingTerminated = true + + log.Debug().Msgf(color.HiMagentaString("[HOT RELOAD] Sending SIGTERM to PID %d", cmd.Process.Pid)) + if e := cmd.Process.Signal(syscall.SIGTERM); e != nil { + log.Error().Err(e).Msg(color.HiMagentaString("[HOT RELOAD] Failed to send SIGTERM")) + } + // wait up to 10 sec for the process to exit + for i := 0; i < 10; i++ { + if !util.IsProcessRunning(cmd.Process) { + // process has been killed so we break out + break + } + if i == 5 { + log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] Still waiting for process exit status")) + } + time.Sleep(time.Second) + } + + // SIGTERM may not work on Windows so we try SIGKILL + if util.IsProcessRunning(cmd.Process) { + log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] Process still hasn't fully exited, attempting SIGKILL")) + if e := cmd.Process.Kill(); e != nil { + log.Error().Err(e).Msg(color.HiMagentaString("[HOT RELOAD] Failed to send SIGKILL")) + } + } + + cmd = nil + } + + processMutex.Lock() + + if lastUpdateEvent.After(secretsFetchedAt) { + processMutex.Unlock() + return + } + + beingTerminated = false + WaitGroup.Add(1) + + if shouldRestartProcess { + log.Info().Msg(color.HiMagentaString("[HOT RELOAD] Environment changes detected. Reloading process...")) + } + + // start the process + log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", environment.SecretsCount)) + cmd, err = util.RunCommand(commandFlag, args, environment.Variables) + if err != nil { + defer WaitGroup.Done() + util.HandleError(err) + } + + go func() { + defer processMutex.Unlock() + defer WaitGroup.Done() + + exitCode, err := WaitForExitCommand(cmd) + + // ignore errors if we are being terminated + if !beingTerminated { + if err != nil { + if strings.HasPrefix(err.Error(), "exec") || strings.HasPrefix(err.Error(), "fork/exec") { + log.Error().Err(err).Msg("Failed to execute command") + } + if err.Error() != ErrManualSignalInterrupt.Error() { + log.Error().Err(err).Msg("Process exited with error") + } + } + + os.Exit(exitCode) + } + }() } - reservedEnvVarPrefixes = []string{ - "XDG_", - "LC_", + initialEnvironment, err := createInjectableEnvironment(request, projectConfigDir, secretOverriding, expandSecrets, token) + if err != nil { + util.HandleError(err, "[HOT RELOAD] Failed to fetch secrets") } -) + startProcess(initialEnvironment) + recheckSecretsChannel := make(chan bool, 1) + + // this is the only logic strictly related to watch mode, the rest is shared with non-watch mode + if watchMode { + log.Info().Msg(color.HiMagentaString("[HOT RELOAD] Watching for secret changes...")) + + // a simple goroutine that triggers the recheckSecretsChan every 5 seconds + go func() { + for { + time.Sleep(5 * time.Second) + recheckSecretsChannel <- true + } + }() + + for { + <-recheckSecretsChannel + watchMutex.Lock() + + newEnvironmentVariables, err := createInjectableEnvironment(request, projectConfigDir, secretOverriding, expandSecrets, token) + if err != nil { + log.Error().Err(err).Msg("[HOT RELOAD] Failed to fetch secrets") + continue + } + + if newEnvironmentVariables.ETag != currentETag { + startProcess(newEnvironmentVariables) + } else { + log.Debug().Msg("[HOT RELOAD] No changes detected in secrets, not reloading process") + } + + watchMutex.Unlock() + + } + } +} func filterReservedEnvVars(env map[string]models.SingleEnvironmentVariable) { + var ( + reservedEnvVars = []string{ + "HOME", "PATH", "PS1", "PS2", + "PWD", "EDITOR", "XAUTHORITY", "USER", + "TERM", "TERMINFO", "SHELL", "MAIL", + } + + reservedEnvVarPrefixes = []string{ + "XDG_", + "LC_", + } + ) + for _, reservedEnvName := range reservedEnvVars { if _, ok := env[reservedEnvName]; ok { delete(env, reservedEnvName) @@ -208,251 +338,6 @@ func init() { runCmd.Flags().String("project-config-dir", "", "explicitly set the directory where the .infisical.json resides") } -// Will execute a single command and pass in the given secrets into the process -func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string, reloadParameters models.ExecuteCommandHotReloadParameters, token *models.TokenDetails) { - ctx, cancelCtx := context.WithCancel(context.Background()) - defer cancelCtx() - - // Set up signal handling - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - if reloadParameters.Enabled { - handleHotReloadCleanup(sigChan, cancelCtx) - } - - var currentCmd *exec.Cmd - - startCmd := func() error { - if currentCmd != nil { - terminateProcessGroup(currentCmd) - } - - command := args[0] - argsForCommand := args[1:] - - log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", secretsCount)) - - currentCmd = exec.Command(command, argsForCommand...) - currentCmd.Stdin = os.Stdin - currentCmd.Stdout = os.Stdout - currentCmd.Stderr = os.Stderr - currentCmd.Env = env - - if reloadParameters.Enabled { - currentCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - go runCommandWithReloading(currentCmd) - } else { - return execCmd(currentCmd) - } - return nil - } - - err := startCmd() // Initial command start - if err != nil { - if err.Error() == ErrManualInterrupt.Error() { - log.Debug().Msg("Process was terminated manually by the user") - os.Exit(1) - } - util.HandleError(err, "Failed to run command") - } - - // This part is only relevant when the --watch flag is passed - if reloadParameters.Enabled { - runHotReloadLoop(ctx, &reloadParameters, token, ¤tCmd, &env, &secretsCount, startCmd) - } -} - -func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env []string, reloadParameters models.ExecuteCommandHotReloadParameters, token *models.TokenDetails) { - ctx, cancelCtx := context.WithCancel(context.Background()) - defer cancelCtx() - - // Set up signal handling - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - - if reloadParameters.Enabled { - handleHotReloadCleanup(sigChan, cancelCtx) - } - - var currentCmd *exec.Cmd - - startCmd := func() error { - if currentCmd != nil { - terminateProcessGroup(currentCmd) - } - - shell := [2]string{"sh", "-c"} - if runtime.GOOS == "windows" { - shell = [2]string{"cmd", "/C"} - } else { - currentShell := os.Getenv("SHELL") - if currentShell != "" { - shell[0] = currentShell - } - } - - currentCmd = exec.Command(shell[0], shell[1], fullCommand) - currentCmd.Stdin = os.Stdin - currentCmd.Stdout = os.Stdout - currentCmd.Stderr = os.Stderr - currentCmd.Env = env - - log.Info().Msgf(color.GreenString("Injecting %v Infisical secrets into your application process", secretsCount)) - log.Debug().Msgf("executing command: %s %s %s \n", shell[0], shell[1], fullCommand) - - if reloadParameters.Enabled { - currentCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - go runCommandWithReloading(currentCmd) - } else { - return execCmd(currentCmd) - } - return nil - } - - err := startCmd() // Initial command start - if err != nil { - if err.Error() == ErrManualInterrupt.Error() { - log.Debug().Msg("Process was terminated manually by the user") - os.Exit(1) - } - util.HandleError(err, "Failed to run command") - } - - // This part is only relevant when the --watch flag is passed - if reloadParameters.Enabled { - runHotReloadLoop(ctx, &reloadParameters, token, ¤tCmd, &env, &secretsCount, startCmd) - } -} - -func execCmd(cmd *exec.Cmd) error { - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start command: %v", err) - } - - if err := cmd.Wait(); err != nil { - return err // Return the raw error for more detailed handling in the caller - } - - return nil -} - -func runCommandWithReloading(cmd *exec.Cmd) { - err := cmd.Run() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - if exitErr.ExitCode() == -1 { - log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] Process was terminated as part of reload, this is expected behavior")) - } else { - util.HandleError(err, "Command execution failed") - } - } else { - log.Error().Err(err).Msg("[HOT RELOAD] Command execution failed") - } - } else { - log.Debug().Msg(color.HiMagentaString("[HOT RELOAD] Command exited without faults")) - os.Exit(0) - } -} - -func handleHotReloadCleanup(sigChan chan os.Signal, cancelCtx context.CancelFunc) { - log.Info().Msgf(color.YellowString("[HOT RELOAD] Watching for secret changes...")) - go func() { - <-sigChan - log.Info().Msg("Received termination signal. Cleaning up...") - cancelCtx() - }() -} - -func terminateProcessGroup(cmd *exec.Cmd) { - if cmd == nil || cmd.Process == nil { - return - } - - log.Info().Msg(color.HiMagentaString("[HOT RELOAD] Terminating existing process group...")) - - pgid, err := syscall.Getpgid(cmd.Process.Pid) - if err == nil { - // Send SIGTERM to the process group - if err := syscall.Kill(-pgid, syscall.SIGTERM); err != nil { - log.Error().Err(err).Msg("[HOT RELOAD] Failed to terminate process group") - } - - // Wait for a short time to allow for graceful shutdown - time.Sleep(2 * time.Second) - - // If the process is still running, force kill the process group - if cmd.ProcessState == nil { - if err := syscall.Kill(-pgid, syscall.SIGKILL); err != nil { - log.Error().Err(err).Msg("[HOT RELOAD] Failed to kill process group") - } - } - } else { - log.Error().Err(err).Msg("[HOT RELOAD] Failed to get process group ID") - } - - // Wait for the process to finish - _, err = cmd.Process.Wait() - if err != nil { - if err.Error() != "wait: no child processes" { - log.Error().Err(err).Msg("[HOT RELOAD] Error waiting for process to terminate") - } - } -} - -func runHotReloadLoop( - ctx context.Context, - reloadParameters *models.ExecuteCommandHotReloadParameters, - token *models.TokenDetails, - currentCmd **exec.Cmd, - env *[]string, - secretsCount *int, - startCmd func() error, -) { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - log.Debug().Msg("Exiting hot reload...") - if *currentCmd != nil { - terminateProcessGroup(*currentCmd) - } - return - case <-ticker.C: - log.Debug().Msg("Checking for environment updates...") - injectableEnvironment, err := createInjectableEnvironment( - reloadParameters.GetSecretsDetails, - reloadParameters.ProjectConfigDir, - reloadParameters.SecretOverriding, - reloadParameters.ExpandSecrets, - token, - ) - if err != nil { - log.Error().Err(err).Msg("Failed to fetch new secrets") - continue - } - - if injectableEnvironment.ETag != reloadParameters.CurrentETag { - log.Info().Msg("[HOT RELOAD] Environment changed. Reloading application...") - reloadParameters.CurrentETag = injectableEnvironment.ETag - *env = injectableEnvironment.Variables - *secretsCount = injectableEnvironment.SecretsCount - - // Start a new process (this will also terminate the existing one if any) - err := startCmd() - if err != nil { - log.Error().Err(err).Msg("[HOT RELOAD] Failed to restart command") - continue - } - } else { - log.Debug().Msg("Not reloading because environments are identical") - } - } - } -} - func createInjectableEnvironment(request models.GetAllSecretsParameters, projectConfigDir string, secretOverriding bool, shouldExpandSecrets bool, token *models.TokenDetails) (models.InjectableEnvironmentResult, error) { secrets, err := util.GetAllEnvironmentVariables(request, projectConfigDir) @@ -511,3 +396,22 @@ func createInjectableEnvironment(request models.GetAllSecretsParameters, project SecretsCount: len(secretsByKey), }, nil } + +func WaitForExitCommand(cmd *exec.Cmd) (int, error) { + if err := cmd.Wait(); err != nil { + // ignore errors + cmd.Process.Signal(os.Kill) // #nosec G104 + + if exitError, ok := err.(*exec.ExitError); ok { + return exitError.ExitCode(), exitError + } + + return 2, err + } + + waitStatus, ok := cmd.ProcessState.Sys().(syscall.WaitStatus) + if !ok { + return 2, fmt.Errorf("unexpected ProcessState type, expected syscall.WaitStatus, got %T", waitStatus) + } + return waitStatus.ExitStatus(), nil +} diff --git a/cli/packages/util/exec.go b/cli/packages/util/exec.go new file mode 100644 index 000000000..abf454181 --- /dev/null +++ b/cli/packages/util/exec.go @@ -0,0 +1,95 @@ +package util + +import ( + "os" + "os/exec" + "os/signal" + "runtime" + "strings" + "syscall" + + "github.com/mattn/go-isatty" +) + +func RunCommand(singleCommand string, args []string, env []string) (*exec.Cmd, error) { + var c *exec.Cmd + var err error + + if singleCommand != "" { + c, err = RunCommandFromString(singleCommand, env) + } else { + c, err = RunCommandFromArgs(args, env) + } + + return c, err +} + +func IsProcessRunning(p *os.Process) bool { + err := p.Signal(syscall.Signal(0)) + return err == nil +} + +// For "infisical run -- COMMAND" +func RunCommandFromArgs(command []string, env []string) (*exec.Cmd, error) { + cmd := exec.Command(command[0], command[1:]...) + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := execCommand(cmd) + + return cmd, err +} + +func execCommand(cmd *exec.Cmd) error { + + shouldForward := !isatty.IsTerminal(os.Stdout.Fd()) + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan) + + if err := cmd.Start(); err != nil { + return err + } + + // handle all signals + go func() { + for { + if shouldForward { + // forward to process + sig := <-sigChan + cmd.Process.Signal(sig) + } else { + <-sigChan + } + } + }() + + return nil +} + +// For "infisical run --command=COMMAND" +func RunCommandFromString(command string, env []string) (*exec.Cmd, error) { + shell := [2]string{"sh", "-c"} + if runtime.GOOS == "windows" { + shell = [2]string{"cmd", "/C"} + } else { + // these shells all support the same options we use for sh + shells := []string{"/bash", "/dash", "/fish", "/zsh", "/ksh", "/csh", "/tcsh"} + envShell := os.Getenv("SHELL") + for _, s := range shells { + if strings.HasSuffix(envShell, s) { + shell[0] = envShell + break + } + } + } + cmd := exec.Command(shell[0], shell[1], command) // #nosec G204 nosemgrep: semgrep_configs.prohibit-exec-command + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := execCommand(cmd) + return cmd, err +} From 7e2d093e294949213e7aa01d042251bd5ecca198 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Wed, 28 Aug 2024 05:34:21 +0400 Subject: [PATCH 15/18] Docs: watch mode --- docs/cli/commands/run.mdx | 50 +++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/docs/cli/commands/run.mdx b/docs/cli/commands/run.mdx index 74aa84947..bfa4bd693 100644 --- a/docs/cli/commands/run.mdx +++ b/docs/cli/commands/run.mdx @@ -47,20 +47,20 @@ $ infisical run -- npm run dev Used to fetch secrets via a [machine identity](/documentation/platform/identities/machine-identities) apposed to logged in credentials. Simply, export this variable in the terminal before running this command. ```bash - # Example - export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages. + # Example + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # --plain flag will output only the token, so it can be fed to an environment variable. --silent will disable any update messages. ``` Alternatively, you may use service tokens. Please note, however, that service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). They will be removed in the future in accordance with the deprecation notice and timeline stated [here](https://infisical.com/blog/deprecating-api-keys). + ```bash - # Example - export INFISICAL_TOKEN= + # Example + export INFISICAL_TOKEN= ``` - - + @@ -69,22 +69,30 @@ $ infisical run -- npm run dev To use, simply export this variable in the terminal before running this command. ```bash - # Example - export INFISICAL_DISABLE_UPDATE_CHECK=true + # Example + export INFISICAL_DISABLE_UPDATE_CHECK=true ``` - ### Flags - + + By passing the `watch` flag, you are telling the CLI to watch for changes that happen in your Infisical project. + If secret changes happen, the command you provided will automatically be restarted with the new environment variables attached. + + ```bash + # Example + infisical run --watch -- printenv + ``` + + + Explicitly set the directory where the .infisical.json resides. This is useful for some monorepo setups. ```bash - # Example - infisical run --project-config-dir=/some-dir -- printenv + # Example + infisical run --project-config-dir=/some-dir -- printenv ``` - @@ -172,3 +180,19 @@ $ infisical run -- npm run dev + + +## Automatically reload command when secrets change + +To automatically reload your command when secrets change, use the `--watch` flag. + +```bash +infisical run --watch -- npm run dev +``` + +This will watch for changes in your secrets and automatically restart your command with the new secrets. +When your command restarts, it will have the new environment variables injeceted into it. + + + Please note that this feature is intended for development purposes. It is not recommended to use this in production environments. Generally it's not recommended to automatically reload your application in production when remote changes are made. + \ No newline at end of file From 5138d588db02687871ce44b639334aac17e16d13 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Wed, 28 Aug 2024 05:35:03 +0400 Subject: [PATCH 16/18] Update cli.go --- cli/packages/models/cli.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index d56517c6f..1bad5e327 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -110,15 +110,6 @@ type InjectableEnvironmentResult struct { SecretsCount int } -type ExecuteCommandHotReloadParameters struct { - Enabled bool - GetSecretsDetails GetAllSecretsParameters - ProjectConfigDir string - SecretOverriding bool - ExpandSecrets bool - CurrentETag string -} - type GetAllFoldersParameters struct { WorkspaceId string Environment string From 3c39bf6a0fa4fe1c055433ef47db49172261880e Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Wed, 28 Aug 2024 21:11:09 +0400 Subject: [PATCH 17/18] Add watch interval --- cli/packages/cmd/run.go | 42 +++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index a415d7a27..e25bec669 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -36,6 +36,8 @@ var runCmd = &cobra.Command{ Args: func(cmd *cobra.Command, args []string) error { // Check if the --command flag has been set commandFlagSet := cmd.Flags().Changed("command") + watchIntervalFlagSet := cmd.Flags().Changed("watch-interval") + watchFlagSet := cmd.Flags().Changed("watch") // If the --command flag has been set, check if a value was provided if commandFlagSet { @@ -55,6 +57,20 @@ var runCmd = &cobra.Command{ } } + // If the --watch flag has been set, the --watch-interval flag should also be set + if watchFlagSet && watchIntervalFlagSet { + // Ensure that the --watch-interval flag is set to a positive integer, and is at least 10 seconds + + watchInterval, err := cmd.Flags().GetInt("watch-interval") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + if watchInterval < 5 { + return fmt.Errorf("watch interval must be at least 5 seconds, you passed %d seconds", watchInterval) + } + } + return nil }, Run: func(cmd *cobra.Command, args []string) { @@ -100,6 +116,11 @@ var runCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + watchModeInterval, err := cmd.Flags().GetInt("watch-interval") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + shouldExpandSecrets, err := cmd.Flags().GetBool("expand") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -151,12 +172,12 @@ var runCmd = &cobra.Command{ Set("multi-command", cmd.Flag("command").Value.String()). Set("version", util.CLI_VERSION)) - executeSpecifiedCommand(command, args, watchMode, request, projectConfigDir, shouldExpandSecrets, secretOverriding, token) + executeSpecifiedCommand(command, args, watchMode, watchModeInterval, request, projectConfigDir, shouldExpandSecrets, secretOverriding, token) }, } -func executeSpecifiedCommand(commandFlag string, args []string, watchMode bool, request models.GetAllSecretsParameters, projectConfigDir string, expandSecrets bool, secretOverriding bool, token *models.TokenDetails) { +func executeSpecifiedCommand(commandFlag string, args []string, watchMode bool, watchModeInterval int, request models.GetAllSecretsParameters, projectConfigDir string, expandSecrets bool, secretOverriding bool, token *models.TokenDetails) { var cmd *exec.Cmd var err error @@ -264,7 +285,7 @@ func executeSpecifiedCommand(commandFlag string, args []string, watchMode bool, // a simple goroutine that triggers the recheckSecretsChan every 5 seconds go func() { for { - time.Sleep(5 * time.Second) + time.Sleep(time.Duration(watchModeInterval) * time.Second) recheckSecretsChannel <- true } }() @@ -324,14 +345,15 @@ func filterReservedEnvVars(env map[string]models.SingleEnvironmentVariable) { func init() { rootCmd.AddCommand(runCmd) - runCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + runCmd.Flags().String("token", "", "fetch secrets using service token or machine identity access token") runCmd.Flags().String("projectId", "", "manually set the project ID to fetch secrets from when using machine identity based auth") - runCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from") - runCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") - runCmd.Flags().Bool("include-imports", true, "Import linked secrets ") - runCmd.Flags().Bool("recursive", false, "Fetch secrets from all sub-folders") - runCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") - runCmd.Flags().Bool("watch", false, "Enable reload of application when secrets change") + runCmd.Flags().StringP("env", "e", "dev", "set the environment (dev, prod, etc.) from which your secrets should be pulled from") + runCmd.Flags().Bool("expand", true, "parse shell parameter expansions in your secrets") + runCmd.Flags().Bool("include-imports", true, "import linked secrets ") + runCmd.Flags().Bool("recursive", false, "fetch secrets from all sub-folders") + runCmd.Flags().Bool("secret-overriding", true, "prioritizes personal secrets, if any, with the same name over shared secrets") + runCmd.Flags().Bool("watch", false, "enable reload of application when secrets change") + runCmd.Flags().Int("watch-interval", 10, "interval in seconds to check for secret changes") runCmd.Flags().StringP("command", "c", "", "chained commands to execute (e.g. \"npm install && npm run dev; echo ...\")") runCmd.Flags().StringP("tags", "t", "", "filter secrets by tag slugs ") runCmd.Flags().String("path", "/", "get secrets within a folder path") From 4e9be8ca3cc2e88b84d1a935f09351816056f093 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 29 Aug 2024 17:38:00 +0400 Subject: [PATCH 18/18] Changes --- cli/packages/cmd/run.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index e25bec669..f4c3188ea 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -59,7 +59,7 @@ var runCmd = &cobra.Command{ // If the --watch flag has been set, the --watch-interval flag should also be set if watchFlagSet && watchIntervalFlagSet { - // Ensure that the --watch-interval flag is set to a positive integer, and is at least 10 seconds + // Ensure that the --watch-interval flag is set to a positive integer, and is at least 5 seconds watchInterval, err := cmd.Flags().GetInt("watch-interval") if err != nil { @@ -273,7 +273,7 @@ func executeSpecifiedCommand(commandFlag string, args []string, watchMode bool, initialEnvironment, err := createInjectableEnvironment(request, projectConfigDir, secretOverriding, expandSecrets, token) if err != nil { - util.HandleError(err, "[HOT RELOAD] Failed to fetch secrets") + util.HandleError(err, "Failed to fetch secrets") } startProcess(initialEnvironment) recheckSecretsChannel := make(chan bool, 1) @@ -282,7 +282,7 @@ func executeSpecifiedCommand(commandFlag string, args []string, watchMode bool, if watchMode { log.Info().Msg(color.HiMagentaString("[HOT RELOAD] Watching for secret changes...")) - // a simple goroutine that triggers the recheckSecretsChan every 5 seconds + // a simple goroutine that triggers the recheckSecretsChan every watch interval (defaults to 10 seconds) go func() { for { time.Sleep(time.Duration(watchModeInterval) * time.Second) @@ -406,7 +406,6 @@ func createInjectableEnvironment(request models.GetAllSecretsParameters, project environmentVariables[k] = v.Value } - // Create and sort the env slice using slices.SortFunc env := make([]string, 0, len(environmentVariables)) for key, value := range environmentVariables { env = append(env, key+"="+value)