Executing a command in a VirtualBox VM from the outside


At work, I regularly have to run scripts, tests or commands on a Virtual Machine. To do this, the common approach is to log in via SSH and then just run what I need to run. But sometimes, there are commands that need to be run repeatedly. VirtualBox allows us to do it from outside the machine by using

  vagrant ssh -c '<command>' <vm_name>

which is a huge help.

That's a great way to do it right there. But in our case, it gets a bit more complicated.

In order for this post to make sense to anyone besides myself or my coworkers, I first need to explain briefly how the architecture works.

The architecture

We have a set of VMs, each one has a purpose. Lets call them vm1, vm2.. vmN.

Inside them, there's a directory called /home/vagrant/work (not called work, but you get the idea) where the code-base lives.

The code-base consists of a series of Ruby applications that get run individually (in dev mode) by the developers. They are all under a directory called the same as the VM. For example, /home/vagrant/work/vm1/app1.

And finally (and this is something only I use at the moment), some apps allow me to run them using the rerun gem (which re-runs a certain command when it detects a change in the project files).

For example, I may need to run something like

  $ cd /home/vagrant/work/vm1/app1
  $ rerun bundle exec bin/app1

And then run a specific script in a separate terminal (and probably multiple times, reloading the app each time)

  $ touch delete_me.rb
  $ bundle exec ruby app1

I use touch to force rerun to notice a change in the code files. I do this because it doesn't always detect it when I'm changing files from outside the VM.

As you can see, it's a process that could be much simpler but, at the moment, it's not.

The script

I grew very tired of this complex process. Each time that I needed to run something, it took several steps, a lot of time and, of course, I kept forgetting some of those steps, making me feel increasingly frustrated.

So I came out with a solution. I developed a small script to make things easier.

I'll display it below and then we can take it apart.

  #!/usr/bin/env ruby

  require 'trollop'

  aliases = {
    "ber" => "bundle exec ruby",
    "be"  => "bundle exec",
  }


  opts = Trollop::options do
    banner <<~EOF
      Usage:
        ~/bin/vmexec <vm_name> <app> <command>
    EOF

    opt :rerun, "Use rerun", default: true
    opt :dry, "Dry run. Just outputs the command"
    opt :aliases, "Change aliases", default: true
  end

  vm_name        = ARGV[0]
  dir            = ARGV[1]
  remote_command = ARGV[2]

  if opts[:aliases]
    aliases.each do |aliass, comm|
      remote_command = remote_command.gsub(aliass, comm)
    end
  end

  command = [
    "cd ~/code/work/developer-setup",
    "vagrant ssh -c 'cd /home/vagrant/work/#{vm_name}/#{dir}",
    ("touch delete_me.rb" if opts[:rerun]),
    "#{remote_command}' #{vm_name}",
  ].compact.join(" && ")

  if !opts[:dry]
    system(command)
  else
    puts command
  end

Notice that the script is written in Ruby. I could've used Bash for this, but since I do some string handling, Ruby was a much better choice for me.

Braking it apart

I won't go in order from top to bottom in order to explain related things together

Options

For handling command line options, I'm using the trollop gem.

  require 'trollop'

  opts = Trollop::options do
    banner <<~EOF
      Usage:
        ~/bin/vmexec <vm_name> <app> <command>
    EOF

    opt :rerun, "Use rerun", default: true
    opt :dry, "Dry run. Just outputs the command"
    opt :aliases, "Change aliases", default: true
  end

We have 3 available options, all of them are booleans

rerun

touches a file to force rerun to restart the application. Turned on by default.

dry

makes a dry-run. This means, just output the command that would run. Turned off by default

aliases

replace aliases for the command (more on this later). Turned on by default

Other arguments

Then we have three required positional arguments:

vm_name

The name of the VM where we want our command to run

dir

The directory on which to cd in order to run the command. This coincides with the name of the application we're trying to run the command on.

remote_command

the command we want to run

If you look at how the command is invoked, you'll notice that the arguments go from general to specific.

  $ vmexec <vm_name> <app_name> <command>
  $ vmexec vm1 app3 'ls -la'

Aliases

Then we see a series of aliases I like to use in a Hash. They're also the ones I use inside the virtual machine.

Each of them will get replaced into the remote command if the aliases flag is true.

  aliases = {
    "ber" => "bundle exec ruby",
    "be"  => "bundle exec",
  }

  if opts[:aliases]
    aliases.each do |aliass, comm|
      remote_command = remote_command.gsub(aliass, comm)
    end
  end

This helps write smaller commands, we tend to use bundle exec ruby quite a lot and typing it all the time gets annoying.

The final command

The command that will get actually run (or printed) is quite long.

  command = [
    "cd ~/code/work/vms_dir",
    "vagrant ssh -c 'cd /home/vagrant/work/#{vm_name}/#{dir}",
    ("touch delete_me.rb" if opts[:rerun]),
    "#{remote_command}' #{vm_name}",
  ].compact.join(" && ")

First it changes directory to the folder that has access to the VMs

  "cd ~/code/work/vms_dir"

Then it runs vagrant ssh -c passing a very long command

  "vagrant ssh -c 'cd /home/vagrant/work/#{vm_name}/#{dir}",
  ("touch delete_me.rb" if opts[:rerun]),
  "#{remote_command}' #{vm_name}"

It starts with cding into the correct dir

    "vagrant ssh -c 'cd /home/vagrant/work/#{vm_name}/#{dir}",

Then it touches a file if the rerun flag is set

  ("touch delete_me.rb" if opts[:rerun]),

And then it passes the command

  "#{remote_command}' #{vm_name}",

Compacting and joining everything with &&, so in case anything fails, the command will terminate immediately.

  command = [
    # ...
  ].compact.join(" && ")

This sounds very complex, but it's just a step by step definition of what I explained before (and it's the actual complexity we avoid by delegating it to Ruby).

Dry-run

Finally, we either run the final command or just print it depending on the dry flag

  if !opts[:dry]
    system(command)
  else
    puts command
  end

Conclusion

I know this script is overkill for most of the readers, but that's not the point of showing it. I think there's something very valuable in taking this workflow (or, you might argue, culture) problems and try approaching them with hand crafted tools.

This script saves me a lot of time. Not only by avoiding the context switch of changing terminals, stopping and restarting stuff, but also by ensuring that I can't (and I repeat CAN'T) forget a step and waste a lot of time debugging an error that wasn't an error, but a missed step (I usually forget to restart the applications and end up chasing my own tail for hours).

I hope this article inspired you to solve at least one workflow issue on your own environment. If that's the case, I'm more than happy.

Saluti.