Using whiptail for text-based user interfaces
One of my ongoing projects is to implement a Bash-based raspberry pi provisioning system for hosts in my raspberry pi cluster. This is particularly important given that Debian 11 bullseye was released a number of months ago, and while it is technically possible to upgrade a host in-place from Debian 10 buster to Debian 11 bullseye, this is a lot of work that I'd rather avoid.
In implementing a Bash-based provisioning system, I'll have a system that allows me to rapidly provision a brand-new DietPi (or potentially other OSes in the future, but that's out-of-scope of version 1) automatically. Once the provisioning process is complete, I need only reboot it and potentially set a static IP address on my router and I'll then have a fully functional cluster host that requires no additional intervention (except to update it regularly of course).
The difficulty here is I don't yet have enough hosts in my cluster that I can have a clear server / worker division, since my Hashicorp Nomad and Consul clusters both have 3 server nodes for redundancy rather than 1. It is for this reason I need a system in my provisioning system that can ask me what configuration I want the new host to have.
To do this, I rediscovered the whiptail
command, which is installed by default on pretty much every system I've encountered so far, and it allows you do develop surprisingly flexible text based user interfaces with relatively little effort, so I wanted to share it here.
Unfortunately, while it's very cool and also relatively easy to use, it also has a lot of options and can result in command invocations like this:
whiptail --title "Some title" --inputbox "Enter a hostname:" 10 40 "default_value" 3>&1 1>&2 2>&3;
...and it only gets more complicated from here. In particular the 2>&1 1>&2 2>&3
bit there is a fancy way of flipping the standard output and standard error.
I thought to myself that surely there must be a way that I can simplify this down to make it easier to use, so I implemented a number of wrapper functions:
ask_yesno() {
local question="$1";
whiptail --title "Step ${step_current} / ${step_max}" --yesno "${question}" 40 8;
return "$?"; # Not actually needed, but best to be explicit
}
This first one asks a simple yes/no question. Use it like this:
if ask_yesno "Some question here"; then
echo "Yep!";
else
echo "Nope :-/";
fi
Next up, to ask the user for a string of text:
# Asks the user for a string of text.
# $1 The window title.
# $2 The question to ask.
# $3 The default text value.
# Returns the answer as a string on the standard output.
ask_text() {
local title="$1";
local question="$2";
local default_text="$3";
whiptail --title "${title}" --inputbox "${question}" 10 40 "${default_text}" 3>&1 1>&2 2>&3;
return "$?"; # Not actually needed, but best to be explicit
}
# Asks the user for a password.
# $1 The window title.
# $2 The question to ask.
# $3 The default text value.
# Returns the answer as a string on the standard output.
ask_password() {
local title="$1";
local question="$2";
local default_text="$3";
whiptail --title "${title}" --passwordbox "${question}" 10 40 "${default_text}" 3>&1 1>&2 2>&3;
return "$?"; # Not actually needed, but best to be explicit
}
These both work in the same way - it's just that with ask_password
it uses asterisks instead of the actual characters the user is typing to hide what they are typing. Use them like this:
new_hostname="$(ask_text "Provisioning step 1 / 4" "Enter a hostname:" "${HOSTNAME}")";
sekret="$(ask_password "Provisioning step 2 / 4" "Enter a sekret:")";
The default value there is of course optional, since in Bash if a variable does not hold a value it is simply considered to be empty.
Finally, I needed a mechanism to ask the user to choose at most 1 value from a predefined list:
# Asks the user to choose at most 1 item from a list of items.
# $1 The window title.
# $2..$n The items that the user must choose between.
# Returns the chosen item as a string on the standard output.
ask_multichoice() {
local title="$1"; shift;
local args=();
while [[ "$#" -gt 0 ]]; do
args+=("$1");
args+=("$1");
shift;
done
whiptail --nocancel --notags --menu "$title" 15 40 5 "${args[@]}" 3>&1 1>&2 2>&3;
return "$?"; # Not actually needed, but best to be explicit
}
This one is a bit special, as it stores the items in an array before passing it to whiptail
. This works because of word splitting, which is when the shell will substitute a variable with it's contents before splitting the arguments up. Here's how you'd use it:
choice="$(ask_multichoice "How should I install Consul?" "Don't install" "Client mode" "Server mode")";
As an aside, the underlying mechanics as to why this works is best explained by example. Consider the following:
oops="a value with spaces";
node src/index.mjs --text $oops;
Here, we store value we want to pass to the --text
argument in a variable. Unfortunately, we didn't quote $oops
when we passed it to our fictional Node.js script, so the shell actually interprets that Node.js call like this:
node src/index.mjs --text a value with spaces;
That's not right at all! Without the quotes around a value with spaces
there, process.argv
will actually look like this:
[
'/usr/local/lib/node/bin/node',
'/tmp/test/src/index.mjs',
'--text',
'a',
'value',
'with',
'spaces'
]
The a value with spaces
there has been considered by the Node.js subprocess as 4 different values!
Now, if we include the quotes there instead like so:
oops="a value with spaces";
node src/index.mjs --text "$oops";
...the shell will correctly expand it to look like this:
node src/index.mjs --text "a value with spaces";
... which then looks like this to our Node.js subprocess:
[
'/usr/local/lib/node/bin/node',
'/tmp/test/src/index.mjs',
'--text',
'a value with spaces'
]
Much better! This is important to understand, as when we start talking about arrays in Bash things start to work a little differently. Consider this example:
items=("an apple" "a banana" "an orange")
/tmp/test.mjs --text "${item[@]}"
Can you guess what process.argv
will look like? The result might surprise you:
[
'/usr/local/lib/node/bin/node',
'/tmp/test.mjs',
'--text',
'an apple',
'a banana',
'an orange'
]
Each element of the Bash array has been turned into a separate item - even when we quoted it and the items themselves contain spaces! What's going on here?
In this case, we used [@]
when addressing our items
Bash array, which causes Bash to expand it like this:
/tmp/test.mjs --text "an apple" "a banana" "an orange"
....so it quotes each item in the array separately. If we forgot the quotes instead like this:
/tmp/test.mjs --text ${item[@]}
...we would get this in process.argv
:
[
'/usr/local/lib/node/bin/node',
'/tmp/test.mjs',
'--text',
'an',
'apple',
'a',
'banana',
'an',
'orange'
]
Here, Bash still expands each element separately, but does not quote each item. Because each item isn't quoted, when the command is actually executed, it splits everything a second time!
As a side note, if you want all the items in a Bash array in a single quoted item, you need to use an asterisk *
instead of an at-sign @
like so:
/tmp/test.mjs --text "${a[*]}";
....which would yield the following process.argv
:
[
'/usr/local/lib/node/bin/node',
'/tmp/test.mjs',
'--text',
'an apple a banana an orange'
]
With that, we have a set of functions that make whiptail
much easier to use. Once it's finished, I'll write a post on my Bash-based cluster host provisioning script and explain my design philosophy behind it and how it works.