kaukas

Workflows with FZF

FZF is fantastic!

Well, of course it is. But it continues to amaze me how much mileage one can get from such a simple idea: a list selector that consumes stdin and prints to stdout.

At some point I hacked together an FZF-based workflow to work with kubectl. In retrospect, kubectl-fzf might have done the job. But my workflow ended up very specific and ergonomic.

The setup

Let's not use the actual kubectl (not that easy to quickly set up a meaningful playground). Instead let's assume a hypothetical setup of virtual machines that can be queried via a set of APIs. Our task is to log into one machine by executing a complex BASH command, say:

ssh -o ForwardAgent cluster-machine-name \
  secrets-manager run \
  bundle exec rails console

To construct such a command we need to:

  • Pick the name of the cluster (cluster).
  • Pick a machine name from that cluster (cluster-machine-name).
  • Choose a command to run (bundle exec rails console).

Let's use BASH functions in place of the real API calls. It is probably a good idea to wrap the API calls with functions anyway.

list_clusters() {
  echo "north"
  echo "west"
  echo "south"
  echo "east"
}

list_machines_for_cluster() {
  for service in Rails Postgres Redis Nginx; do
    # The cluster name is part of the machine name.
    printf "$1-"
    cat /dev/urandom | base32 | head -c 5
    printf "\t$service\tUp for 3d 14h\n"
  done
}

Then we query a list clusters

$ list_clusters
north
west
south
east

And we can get a list of machines for a given cluster along with some extra information:

$ list_machines_for_cluster north
north-X6OZD    Rails   Up for 3d 14h
north-7Q5OD    Postgres    Up for 3d 14h
north-2B6O3    Redis   Up for 3d 14h
north-ONF4B    Nginx   Up for 3d 14h

FZF to The Rescue

Let's first pick a cluster:

$ list_clusters | fzf --prompt='cluster: '

You can choose the cluster with arrows or fuzzy filter:

  east
  south
  west
> north
  4/4
cluster:

After you pick an option FZF prints it to stdout. And so, we have the cluster of choice.

$ cluster=$(list_clusters | fzf --prompt='cluster: ')

Next let's pick the machine to work with:

$ list_machines_for_cluster "$cluster" | \
  fzf --prompt='machine: '
west-RM33M Rails   Up for 3d 14h

Oh, we get too much output. Let's pick the first field only:

$ list_machines_for_cluster "$cluster" | \
  fzf --prompt='machine: ' | \
  awk '{ print $1 }'
west-FDYBH

I often find myself waiting for a VM to start so that I could connect to it. Rerunning the script is rather annoying. I'd rather have a way to refresh the list of machines in place. First we need to export the function so that the BASH subprocess could see it, too.

$ export -f list_machines_for_cluster
$ machine=$(\
  list_machines_for_cluster "$cluster" | \
  fzf --prompt='machine: ' \
    --bind="ctrl-r:reload(bash -c \"list_machines_for_cluster \"$cluster\"\")" | \
  awk '{ print $1 }')

Now you can hit "ctrl r" to refresh the list of machines. By the way, the bash -c prefix is needed because list_machines_for_cluster is not an executable but a function that only BASH knows about.

And so we have our machine name. What do we want to run on the machine?

$ remote_command=$(\
  printf "bundle exec rails console\n/bin/bash" | \
  fzf --prompt='command: ')

Let's construct the final command and run it:

$ ssh_command="ssh -o ForwardAgent $machine secrets-manager run $remote_command"
$ bash -c "$ssh_command"

Custom Commands

The above is already useful. One of the extensions I made was to give the user (myself) a chance to tweak the command before running it (don't ask…). Instead of running it as part of our workflow we could print the command to the terminal and expect the user to copy-paste-tweak-run it. Fortunately, there is a more convenient way:

print_to_bash_input() {
  # Sends characters directly to the terminal input.
  python -c '
import fcntl, sys, termios
for c in sys.argv[1]:
  fcntl.ioctl(0, termios.TIOCSTI, c)
  ' "$@"
}

We can allow entering a custom command:

$ remote_command=$(\
  printf "bundle exec rails console\n/bin/bash\na custom command" | \
  fzf --prompt='command: ')
$ if [ "$remote_command" = "a custom command" ]; then
  print_to_bash_input "ssh -o ForwardAgent $machine secrets-manager run "
  printf "\n\nManually complete the command and press <Enter> to run it\n"
else
  ssh_command="ssh -o ForwardAgent $machine secrets-manager run $remote_command"
  bash -c "$ssh_command"
fi

No more copy-pasting! Edit and run directly.

Conclusion

We barely scratched the surface of what FZF can do (man fzf is not short). In real life I, too, used many more bells and whistles. But even the above significantly simplifies the workflow.

Here's the final script that you can save and run, in its entirety:

list_clusters() {
  echo "north"
  echo "west"
  echo "south"
  echo "east"
}

list_machines_for_cluster() {
  for service in Rails Postgres Redis Nginx; do
    printf "$1-"
    cat /dev/urandom | base32 | head -c 5
    printf "\t$service\tUp for 3d 14h\n"
  done
}
export -f list_machines_for_cluster

print_to_bash_input() {
  python -c '
import fcntl, sys, termios
for c in sys.argv[1]:
  fcntl.ioctl(0, termios.TIOCSTI, c)
  ' "$@"
}

cluster=$(list_clusters | fzf --prompt='cluster: ')

machine=$(list_machines_for_cluster "$cluster" | \
    fzf --prompt='machine: ' --bind="ctrl-r:reload(bash -c \"list_machines_for_cluster \"$cluster\"\")" | \
    awk '{ print $1 }')

remote_command=$(printf "bundle exec rails console\n/bin/bash\na custom command" | fzf --prompt='command: ')
if [ "$remote_command" = "a custom command" ]; then
  print_to_bash_input "ssh -o ForwardAgent $machine secrets-manager run "
  printf "\n\nManually complete the command and press <Enter> to run it\n"
else
  ssh_command="ssh -o ForwardAgent $machine secrets-manager run $remote_command"
  bash -c "$ssh_command"
fi