bashshelleval

How to properly do "multi-stage" substitution for `eval` and remote `bash -c`?


I have a command which I would like to pre-construct from constants, but there are variables that will come at play during runtime, imagine:

custom_cmd="cmd1 '$CONST1' '$var1' '$CONST2' '$var2' 2> /dev/null || cmd2 '$CONST3' '$var3'; echo $?"

The reason this is put into custom_cmd variable and not substituted at any particular run is because I need to run this in current script's shell (using eval), but also pass it to ssh within bash -c execution.

So if I were to wrap this into a function instead (with $var{1,2,3} as arguments), I would need to make the script allow for executing it standalone (with special argument, but also have it scp over first - definitely want to avoid this) so that it is actually available for the remote bash -c.

However, the $CONST{1,2,3} are actually loaded from a local config file (I do not want to copy that one over and in fact I do not want to parse it more than once), so I would need to be passing them in too. At that point, it becomes very repetitive for multiple ever-so-slightly different invocations anyhow - to have such function.

My issue is that while the CONST{1,2,3} are non-changing, the $var{1,2,3} are basically environment variables. So say at some point $var1 value is meant to be that of $ENV_VAR_X on the target system, but at another it is meant to be $ENV_VAR_Y.

In a way, I would like a two-stage substitution, something I can initialize at the beginning where the constants are known, but then only substitute the environment variable names later (when it goes to eval or bash -c).

Is there such a thing?


Solution

  • Building commands in strings is problematic. You should prefer functions instead, and as @CharlesDuffy observed in comments, that does not produce much practical difficulty for sending a function to bash -c.

    Additionally, by using indirect expansions, you can avoid having to expand some parts of the command more times than others.

    Consider this:

    # Optional, for convenience:
    constants=("$CONST1" "$CONST2" "$CONST3")
    
    custom_cmd() {
      cmd1 "$1" "${!4}" "$2" "${!5}" 2> /dev/null || cmd2 "$3" "${!6}"
      echo $?
    }
    
    # ...
    
    # execute locally
    custom_cmd "${constants[@]}" "$arg1" "$arg2" "$arg3"
    
    # execute with bash -c
    # (Declares the function and then immediately calls it, with arguments provided
    # on the `bash` command line.)
    bash -c "$(declare -f custom_cmd);"'custom_cmd "$@"' \
      custom_cmd "${constants[@]}" "$arg1" "$arg2" "$arg3"
    

    Note: in the bash -c example, the argument immediately after the command string (custom_cmd) is used as $0 in the executed shell instance. That serves mainly to position the remaining arguments, which are mapped as the shell's positional parameters. Those then become arguments to the custom_cmd function when the custom_cmd "$@" command inside the command string is evaluated.

    That does push the "constants" as function parameters along with the other variables, and I think that's appropriate. They are variables, after all, even if they are declared read-only or simply not expected to change.


    For anyone struggling to follow the logic above, here's a complete working example with a few additional constructs to improve clarity of the output that you could execute with bash -x if it helps:

    $ cat tst.sh
    #!/usr/bin/env bash
    
    CONST1='c * 1'
    CONST2='c $RANDOM 2'
    CONST3='c 3'
    
    localvar='something'
    
    arg1='RANDOM'
    arg2='localvar'
    arg3='TMP'
    
    # Optional, for convenience:
    constants=("$CONST1" "$CONST2" "$CONST3")
    
    custom_cmd() {
      echo "\$1=\"$1\"" "\$4=\"$4\"->\"${!4}\"" "\$2=\"$2\"" "\$5=\"$5\"->\"${!5}\"" 2> /dev/null || echo "\$3=\"$3\"" "\$6=\"$6\"->\"${!6}\""
      echo "\$?=\"$?\""
    }
    
    echo 'execute locally:'
    custom_cmd "${constants[@]}" "$arg1" "$arg2" "$arg3"
    
    echo 'execute with bash -c:'
    # (Declares the function and then immediately calls it, with arguments provided
    # on the `bash` command line.)
    bash -c "$(declare -f custom_cmd);"'custom_cmd "$@"' \
      _ "${constants[@]}" "$arg1" "$arg2" "$arg3"
    

    $ ./tst.sh
    execute locally:
    $1="c * 1" $4="RANDOM"->"10144" $2="c $RANDOM 2" $5="localvar"->"something"
    $?="0"
    execute with bash -c:
    $1="c * 1" $4="RANDOM"->"289" $2="c $RANDOM 2" $5="localvar"->""
    $?="0"
    

    $ bash -x ./tst.sh
    + CONST1='c * 1'
    + CONST2='c $RANDOM 2'
    + CONST3='c 3'
    + localvar=something
    + arg1=RANDOM
    + arg2=localvar
    + arg3=TMP
    + constants=("$CONST1" "$CONST2" "$CONST3")
    + echo 'execute locally:'
    execute locally:
    + custom_cmd 'c * 1' 'c $RANDOM 2' 'c 3' RANDOM localvar TMP
    + echo '$1="c * 1"' '$4="RANDOM"->"16277"' '$2="c $RANDOM 2"' '$5="localvar"->"something"'
    $1="c * 1" $4="RANDOM"->"16277" $2="c $RANDOM 2" $5="localvar"->"something"
    + echo '$?="0"'
    $?="0"
    + echo 'execute with bash -c:'
    execute with bash -c:
    ++ declare -f custom_cmd
    + bash -c 'custom_cmd ()
    {
        echo "\$1=\"$1\"" "\$4=\"$4\"->\"${!4}\"" "\$2=\"$2\"" "\$5=\"$5\"->\"${!5}\"" 2> /dev/null || echo "\$3=\"$3\"" "\$6=\"$6\"->\"${!6}\"";
        echo "\$?=\"$?\""
    };custom_cmd "$@"' _ 'c * 1' 'c $RANDOM 2' 'c 3' RANDOM localvar TMP
    $1="c * 1" $4="RANDOM"->"8394" $2="c $RANDOM 2" $5="localvar"->""
    $?="0"