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.