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 }