1

For a larger project that's not relevant, I need to collect system stats from the local system or a remote system. Since I'm collecting the same stats either way, I'm preventing code duplication by storing the stats-collecting commands in a Bash associative array.

declare -A stats_cmds
# Actually contains many more key:value pairs, similar style
stats_cmds=([total_ram]="$(free -m | awk '/^Mem:/{print $2}')")

I can collect local system stats like this:

get_local_system_stats()
{
    # Collect stats about local system
    complex_data_structure_that_doesnt_matter=${stats_cmds[total_ram]}
    # Many more similar calls here
}

A precondition of my script is that ~/.ssh/config is setup such that ssh $SSH_HOSTNAME works without any user input. I would like something like this:

get_remote_system_stats()
{
    # Collect stats about remote system
    complex_data_structure_that_doesnt_matter=`ssh $SSH_HOSTNAME ${stats_cmds[total_ram]}`
}

I've tried every combination of single quotes, double quotes, backticks and such that I can imagine. Some combinations result in the stats command getting executed too early (bash: 7986: command not found), others cause syntax errors, others return null (single quotes around the stats command) but none store the proper result in my data structure.

How can I evaluate a command, stored in an associative array, on a remote system via SSH and store the result in a data structure in my local script?

dfarrell07
  • 2,872
  • 2
  • 21
  • 26
  • This is still an open question for which I'm actively seeking an answer. As described in comments on gniourf_gniourif's answer, it doesn't support both remote and local execution. – dfarrell07 Jun 25 '14 at 20:24
  • Is it still open? Whether the comment added in October '14 addresses your issues with the prior answer appears not to have been answered. – Charles Duffy May 26 '15 at 22:11
  • Yes, it's still open. I'm using a work-around based on a combination of both solutions. https://github.com/dfarrell07/wcbench/blob/master/wcbench.sh#L56-L82 – dfarrell07 May 27 '15 at 14:50
  • Eww. Then again, some amount of "ewww" goes with the territory -- embedding arbitrary shell commands in strings is at least moderately evil. I'd at least consider encapsulating them in functions -- then you can either directly run the function locally, or use `declare -f` to ask the shell to give you a string which, when evaluated, will define it remotely. – Charles Duffy May 27 '15 at 15:18
  • BTW, could you clarify *how* that comment failed to address your question (and why you still need that workaround)? – Charles Duffy May 27 '15 at 15:19
  • To clarify what I mean -- with the exact code in your linked project, `result=$(eval "${remote_stats_cmds[total_ram]}")` -- as I suggested last October -- *does* give you a local result for the command in question. – Charles Duffy May 27 '15 at 15:21
  • ...I'll add a full answer that's more explicit on the point. – Charles Duffy May 27 '15 at 15:23
  • BTW, the other advantage of using functions rather than associative arrays for storing code is that you don't have to do that gawdawful quoting by hand, but can just write your code naturally, and ask the shell to quote it for you. – Charles Duffy May 27 '15 at 15:30
  • BTW, I'm curious as to how `if "$VERBOSE" = true` works in your upstream code; shouldn't that be `if [ "$VERBOSE" = true ]` or `if test "$VERBOSE" = true`? – Charles Duffy May 27 '15 at 17:28

2 Answers2

2

Make sure that the commands you store in your array don't get expanded when you assign your array!

Also note that the complex-looking quoting style is necessary when nesting single quotes. See this SO post for an explanation.

stats_cmds=([total_ram]='free -m | awk '"'"'/^Mem:/{print $2}'"'"'')

And then just launch your ssh as:

sh "$ssh_hostname" "${stats_cmds[total_ram]}"

(yeah, I lowercased your variable name because uppercase variable names in Bash are really sick). Then:

get_local_system_stats() {
    # Collect stats about local system
    complex_data_structure_that_doesnt_matter=$( ${stats_cmds[total_ram]} )
    # Many more similar calls here
}

and

get_remote_system_stats() {
    # Collect stats about remote system
    complex_data_structure_that_doesnt_matter=$(ssh "$ssh_hostname" "${stats_cmds[total_ram]}")
}
Community
  • 1
  • 1
gniourf_gniourf
  • 44,650
  • 9
  • 93
  • 104
  • Converting the awk command to use double quotes breaks the print (entire line is printed, not field two). Working on finding a way around this... – dfarrell07 Jun 23 '14 at 19:57
  • Using [this method](http://stackoverflow.com/questions/1250079/escaping-single-quotes-within-single-quoted-strings) to nest single quotes seems to work. Still seeing "bash: : command not found". Debugging... – dfarrell07 Jun 23 '14 at 20:09
  • Using the method currently described in this answer, I can successfully store remote stats but see errors for local stats. The total_ram example stores the full output of `free -m`, for example – dfarrell07 Jun 24 '14 at 14:03
  • @dfarrell07, for local commands, use `result=$(eval "${cmd_stats[total_ram]}")`; this gives you a similar effect to the external shell invocation done by `ssh`. – Charles Duffy Oct 14 '14 at 13:41
1

First, I'm going to suggest an approach that makes minimal changes to your existing implementation. Then, I'm going to demonstrate something closer to best practices.


Smallest Modification

Given your existing code:

declare -A remote_stats_cmds
remote_stats_cmds=([total_ram]='free -m | awk '"'"'/^Mem:/{print $2}'"'"''
            [used_ram]='free -m | awk '"'"'/^Mem:/{print $3}'"'"''
            [free_ram]='free -m | awk '"'"'/^Mem:/{print $4}'"'"''
            [cpus]='nproc'
            [one_min_load]='uptime | awk -F'"'"'[a-z]:'"'"' '"'"'{print $2}'"'"' | awk -F "," '"'"'{print $1}'"'"' | tr -d " "'
            [five_min_load]='uptime | awk -F'"'"'[a-z]:'"'"' '"'"'{print $2}'"'"' | awk -F "," '"'"'{print $2}'"'"' | tr -d " "'
            [fifteen_min_load]='uptime | awk -F'"'"'[a-z]:'"'"' '"'"'{print $2}'"'"' | awk -F "," '"'"'{print $3}'"'"' | tr -d " "'
            [iowait]='cat /proc/stat | awk '"'"'NR==1 {print $6}'"'"''
            [steal_time]='cat /proc/stat | awk '"'"'NR==1 {print $9}'"'"'')

...one can evaluate these locally as follows:

result=$(eval "${remote_stat_cmds[iowait]}")
echo "$result" # demonstrate value retrieved

...or remotely as follows:

result=$(ssh "$hostname" bash <<<"${remote_stat_cmds[iowait]}")
echo "$result" # demonstrate value retrieved

No separate form is required.


The Right Thing

Now, let's talk about an entirely different way to do this:

# no awful nested quoting by hand!
collect_total_ram() { free -m | awk '/^Mem:/ {print $2}'; }
collect_used_ram()  { free -m | awk '/^Mem:/ {print $3}'; }
collect_cpus()      { nproc; }

...and then, to evaluate locally:

result=$(collect_cpus)

...or, to evaluate remotely:

result=$(ssh "$hostname" bash <<<"$(declare -f collect_cpus); collect_cpus")

...or, to iterate through defined functions with the collect_ prefix and do both of these things:

declare -A local_results
declare -A remote_results
while IFS= read -r funcname; do
  local_results["${funcname#collect_}"]=$("$funcname")
  remote_results["${funcname#collect_}"]=$(ssh "$hostname" bash <<<"$(declare -f "$funcname"); $funcname")
done < <(compgen -A function collect_)

...or, to collect all the items into a single remote array in one pass, avoiding extra SSH round-trips and not eval'ing or otherwise taking security risks with results received from the remote system:

remote_cmd=""
while IFS= read -r funcname; do
  remote_cmd+="$(declare -f "$funcname"); printf '%s\0' \"$funcname\" \"\$(\"$funcname\")\";"
done < <(compgen -A function collect_)

declare -A remote_results=( )
while IFS= read -r -d '' funcname && IFS= read -r -d '' result; do
  remote_results["${funcname#collect_}"]=$result
done < <(ssh "$hostname" bash <<<"$remote_cmd")
Charles Duffy
  • 280,126
  • 43
  • 390
  • 441