github.com/Racer159/jackal@v0.32.7-0.20240401174413-0bd2339e4f2e/src/pkg/packager/actions/actions.go (about)

     1  // SPDX-License-Identifier: Apache-2.0
     2  // SPDX-FileCopyrightText: 2021-Present The Jackal Authors
     3  
     4  // Package actions contains functions for running component actions within Jackal packages.
     5  package actions
     6  
     7  import (
     8  	"context"
     9  	"fmt"
    10  	"regexp"
    11  	"runtime"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/Racer159/jackal/src/internal/packager/template"
    16  	"github.com/Racer159/jackal/src/pkg/message"
    17  	"github.com/Racer159/jackal/src/pkg/utils"
    18  	"github.com/Racer159/jackal/src/pkg/utils/exec"
    19  	"github.com/Racer159/jackal/src/types"
    20  	"github.com/defenseunicorns/pkg/helpers"
    21  )
    22  
    23  // Run runs all provided actions.
    24  func Run(cfg *types.PackagerConfig, defaultCfg types.JackalComponentActionDefaults, actions []types.JackalComponentAction, valueTemplate *template.Values) error {
    25  	for _, a := range actions {
    26  		if err := runAction(cfg, defaultCfg, a, valueTemplate); err != nil {
    27  			return err
    28  		}
    29  	}
    30  	return nil
    31  }
    32  
    33  // Run commands that a component has provided.
    34  func runAction(cfg *types.PackagerConfig, defaultCfg types.JackalComponentActionDefaults, action types.JackalComponentAction, valueTemplate *template.Values) error {
    35  	var (
    36  		ctx        context.Context
    37  		cancel     context.CancelFunc
    38  		cmdEscaped string
    39  		out        string
    40  		err        error
    41  		vars       map[string]*template.TextTemplate
    42  
    43  		cmd = action.Cmd
    44  	)
    45  
    46  	// If the action is a wait, convert it to a command.
    47  	if action.Wait != nil {
    48  		// If the wait has no timeout, set a default of 5 minutes.
    49  		if action.MaxTotalSeconds == nil {
    50  			fiveMin := 300
    51  			action.MaxTotalSeconds = &fiveMin
    52  		}
    53  
    54  		// Convert the wait to a command.
    55  		if cmd, err = convertWaitToCmd(*action.Wait, action.MaxTotalSeconds); err != nil {
    56  			return err
    57  		}
    58  
    59  		// Mute the output because it will be noisy.
    60  		t := true
    61  		action.Mute = &t
    62  
    63  		// Set the max retries to 0.
    64  		z := 0
    65  		action.MaxRetries = &z
    66  
    67  		// Not used for wait actions.
    68  		d := ""
    69  		action.Dir = &d
    70  		action.Env = []string{}
    71  		action.SetVariables = []types.JackalComponentActionSetVariable{}
    72  	}
    73  
    74  	if action.Description != "" {
    75  		cmdEscaped = action.Description
    76  	} else {
    77  		cmdEscaped = message.Truncate(cmd, 60, false)
    78  	}
    79  
    80  	spinner := message.NewProgressSpinner("Running \"%s\"", cmdEscaped)
    81  	// Persist the spinner output so it doesn't get overwritten by the command output.
    82  	spinner.EnablePreserveWrites()
    83  
    84  	// If the value template is not nil, get the variables for the action.
    85  	// No special variables or deprecations will be used in the action.
    86  	// Reload the variables each time in case they have been changed by a previous action.
    87  	if valueTemplate != nil {
    88  		vars, _ = valueTemplate.GetVariables(types.JackalComponent{})
    89  	}
    90  
    91  	actionDefaults := actionGetCfg(defaultCfg, action, vars)
    92  
    93  	if cmd, err = actionCmdMutation(cmd, actionDefaults.Shell); err != nil {
    94  		spinner.Errorf(err, "Error mutating command: %s", cmdEscaped)
    95  	}
    96  
    97  	duration := time.Duration(actionDefaults.MaxTotalSeconds) * time.Second
    98  	timeout := time.After(duration)
    99  
   100  	// Keep trying until the max retries is reached.
   101  retryCmd:
   102  	for remaining := actionDefaults.MaxRetries + 1; remaining > 0; remaining-- {
   103  
   104  		// Perform the action run.
   105  		tryCmd := func(ctx context.Context) error {
   106  			// Try running the command and continue the retry loop if it fails.
   107  			if out, err = actionRun(ctx, actionDefaults, cmd, actionDefaults.Shell, spinner); err != nil {
   108  				return err
   109  			}
   110  
   111  			out = strings.TrimSpace(out)
   112  
   113  			// If an output variable is defined, set it.
   114  			for _, v := range action.SetVariables {
   115  				cfg.SetVariable(v.Name, out, v.Sensitive, v.AutoIndent, v.Type)
   116  				if err := cfg.CheckVariablePattern(v.Name, v.Pattern); err != nil {
   117  					message.WarnErr(err, err.Error())
   118  					return err
   119  				}
   120  			}
   121  
   122  			// If the action has a wait, change the spinner message to reflect that on success.
   123  			if action.Wait != nil {
   124  				spinner.Successf("Wait for \"%s\" succeeded", cmdEscaped)
   125  			} else {
   126  				spinner.Successf("Completed \"%s\"", cmdEscaped)
   127  			}
   128  
   129  			// If the command ran successfully, continue to the next action.
   130  			return nil
   131  		}
   132  
   133  		// If no timeout is set, run the command and return or continue retrying.
   134  		if actionDefaults.MaxTotalSeconds < 1 {
   135  			spinner.Updatef("Waiting for \"%s\" (no timeout)", cmdEscaped)
   136  			if err := tryCmd(context.TODO()); err != nil {
   137  				continue retryCmd
   138  			}
   139  
   140  			return nil
   141  		}
   142  
   143  		// Run the command on repeat until success or timeout.
   144  		spinner.Updatef("Waiting for \"%s\" (timeout: %ds)", cmdEscaped, actionDefaults.MaxTotalSeconds)
   145  		select {
   146  		// On timeout break the loop to abort.
   147  		case <-timeout:
   148  			break retryCmd
   149  
   150  		// Otherwise, try running the command.
   151  		default:
   152  			ctx, cancel = context.WithTimeout(context.Background(), duration)
   153  			defer cancel()
   154  			if err := tryCmd(ctx); err != nil {
   155  				continue retryCmd
   156  			}
   157  
   158  			return nil
   159  		}
   160  	}
   161  
   162  	select {
   163  	case <-timeout:
   164  		// If we reached this point, the timeout was reached or command failed with no retries.
   165  		if actionDefaults.MaxTotalSeconds < 1 {
   166  			return fmt.Errorf("command %q failed after %d retries", cmdEscaped, actionDefaults.MaxRetries)
   167  		} else {
   168  			return fmt.Errorf("command %q timed out after %d seconds", cmdEscaped, actionDefaults.MaxTotalSeconds)
   169  		}
   170  	default:
   171  		// If we reached this point, the retry limit was reached.
   172  		return fmt.Errorf("command %q failed after %d retries", cmdEscaped, actionDefaults.MaxRetries)
   173  	}
   174  }
   175  
   176  // convertWaitToCmd will return the wait command if it exists, otherwise it will return the original command.
   177  func convertWaitToCmd(wait types.JackalComponentActionWait, timeout *int) (string, error) {
   178  	// Build the timeout string.
   179  	timeoutString := fmt.Sprintf("--timeout %ds", *timeout)
   180  
   181  	// If the action has a wait, build a cmd from that instead.
   182  	cluster := wait.Cluster
   183  	if cluster != nil {
   184  		ns := cluster.Namespace
   185  		if ns != "" {
   186  			ns = fmt.Sprintf("-n %s", ns)
   187  		}
   188  
   189  		// Build a call to the jackal tools wait-for command.
   190  		return fmt.Sprintf("./jackal tools wait-for %s %s %s %s %s",
   191  			cluster.Kind, cluster.Identifier, cluster.Condition, ns, timeoutString), nil
   192  	}
   193  
   194  	network := wait.Network
   195  	if network != nil {
   196  		// Make sure the protocol is lower case.
   197  		network.Protocol = strings.ToLower(network.Protocol)
   198  
   199  		// If the protocol is http and no code is set, default to 200.
   200  		if strings.HasPrefix(network.Protocol, "http") && network.Code == 0 {
   201  			network.Code = 200
   202  		}
   203  
   204  		// Build a call to the jackal tools wait-for command.
   205  		return fmt.Sprintf("./jackal tools wait-for %s %s %d %s",
   206  			network.Protocol, network.Address, network.Code, timeoutString), nil
   207  	}
   208  
   209  	return "", fmt.Errorf("wait action is missing a cluster or network")
   210  }
   211  
   212  // Perform some basic string mutations to make commands more useful.
   213  func actionCmdMutation(cmd string, shellPref exec.Shell) (string, error) {
   214  	jackalCommand, err := utils.GetFinalExecutableCommand()
   215  	if err != nil {
   216  		return cmd, err
   217  	}
   218  
   219  	// Try to patch the jackal binary path in case the name isn't exactly "./jackal".
   220  	cmd = strings.ReplaceAll(cmd, "./jackal ", jackalCommand+" ")
   221  
   222  	// Make commands 'more' compatible with Windows OS PowerShell
   223  	if runtime.GOOS == "windows" && (exec.IsPowershell(shellPref.Windows) || shellPref.Windows == "") {
   224  		// Replace "touch" with "New-Item" on Windows as it's a common command, but not POSIX so not aliased by M$.
   225  		// See https://mathieubuisson.github.io/powershell-linux-bash/ &
   226  		// http://web.cs.ucla.edu/~miryung/teaching/EE461L-Spring2012/labs/posix.html for more details.
   227  		cmd = regexp.MustCompile(`^touch `).ReplaceAllString(cmd, `New-Item `)
   228  
   229  		// Convert any ${JACKAL_VAR_*} or $JACKAL_VAR_* to ${env:JACKAL_VAR_*} or $env:JACKAL_VAR_* respectively (also TF_VAR_*).
   230  		// https://regex101.com/r/xk1rkw/1
   231  		envVarRegex := regexp.MustCompile(`(?P<envIndicator>\${?(?P<varName>(JACKAL|TF)_VAR_([a-zA-Z0-9_-])+)}?)`)
   232  		get, err := helpers.MatchRegex(envVarRegex, cmd)
   233  		if err == nil {
   234  			newCmd := strings.ReplaceAll(cmd, get("envIndicator"), fmt.Sprintf("$Env:%s", get("varName")))
   235  			message.Debugf("Converted command \"%s\" to \"%s\" t", cmd, newCmd)
   236  			cmd = newCmd
   237  		}
   238  	}
   239  
   240  	return cmd, nil
   241  }
   242  
   243  // Merge the ActionSet defaults with the action config.
   244  func actionGetCfg(cfg types.JackalComponentActionDefaults, a types.JackalComponentAction, vars map[string]*template.TextTemplate) types.JackalComponentActionDefaults {
   245  	if a.Mute != nil {
   246  		cfg.Mute = *a.Mute
   247  	}
   248  
   249  	// Default is no timeout, but add a timeout if one is provided.
   250  	if a.MaxTotalSeconds != nil {
   251  		cfg.MaxTotalSeconds = *a.MaxTotalSeconds
   252  	}
   253  
   254  	if a.MaxRetries != nil {
   255  		cfg.MaxRetries = *a.MaxRetries
   256  	}
   257  
   258  	if a.Dir != nil {
   259  		cfg.Dir = *a.Dir
   260  	}
   261  
   262  	if len(a.Env) > 0 {
   263  		cfg.Env = append(cfg.Env, a.Env...)
   264  	}
   265  
   266  	if a.Shell != nil {
   267  		cfg.Shell = *a.Shell
   268  	}
   269  
   270  	// Add variables to the environment.
   271  	for k, v := range vars {
   272  		// Remove # from env variable name.
   273  		k = strings.ReplaceAll(k, "#", "")
   274  		// Make terraform variables available to the action as TF_VAR_lowercase_name.
   275  		k1 := strings.ReplaceAll(strings.ToLower(k), "jackal_var", "TF_VAR")
   276  		cfg.Env = append(cfg.Env, fmt.Sprintf("%s=%s", k, v.Value))
   277  		cfg.Env = append(cfg.Env, fmt.Sprintf("%s=%s", k1, v.Value))
   278  	}
   279  
   280  	return cfg
   281  }
   282  
   283  func actionRun(ctx context.Context, cfg types.JackalComponentActionDefaults, cmd string, shellPref exec.Shell, spinner *message.Spinner) (string, error) {
   284  	shell, shellArgs := exec.GetOSShell(shellPref)
   285  
   286  	message.Debugf("Running command in %s: %s", shell, cmd)
   287  
   288  	execCfg := exec.Config{
   289  		Env: cfg.Env,
   290  		Dir: cfg.Dir,
   291  	}
   292  
   293  	if !cfg.Mute {
   294  		execCfg.Stdout = spinner
   295  		execCfg.Stderr = spinner
   296  	}
   297  
   298  	out, errOut, err := exec.CmdWithContext(ctx, execCfg, shell, append(shellArgs, cmd)...)
   299  	// Dump final complete output (respect mute to prevent sensitive values from hitting the logs).
   300  	if !cfg.Mute {
   301  		message.Debug(cmd, out, errOut)
   302  	}
   303  
   304  	return out, err
   305  }