Written by Sebastian on Feb 29, 2016 in Dev
Idempotent version updates with Ansible
If you are a seasoned Vagrant user, you know the problem around provisioning. If some software version changes, everybody needs to provision his or her machine again. Otherwise, things will fail eventually. You'll also run into problems if running the provisioning twice fails for some reason (e.g. because a file already exists somewhere).
If you are setting up your provisioning, the first thing you need to make sure is that you can run the setup steps more than once and it works without manual steps in between. Nothing is more frustrating than running
vagrant provision and being left with errors because something is already installed or a file is already at a specific place.
This is easy to do, even with a basic shell provisioner. You can check for the existence of files and test if some software is already installed. It's also pretty easy to test: Just run the provisioning again and see if it works. If not, add appropriate tests and not run the offending commands.
Updating vs. optimizing
Getting the provisioning step to update software to the correct version is more tricky. It's easy with package managers like composer for PHP or pip for Python where you have a file containing all dependencies and their versions and the package manager takes care of the rest. If you need to download, compile and install a library, you are faced with three very different options: You can do all the steps every time when provisioning, which takes time even though most of the time nothing changed. You can also install it once and then forget about it, which means nothing happens if a new version for a software is available. The third thing is figuring out the current version as well as the target version and only run the steps for installing it if they don't match.
With Ansible, the Vagrant provisioner of choice for me, all three ways are possible. It's important to be clear which one you are choosing so you know what drawbacks your solution might have.
To run some steps every time, give them no restriction like
creates for commands and Ansible will do as told. If you are using the
shell module, Ansible will run it every time. If your process is downloading some tar or zip and extracting it, maybe running a setup command as well, Ansible will download it every time and you'll get new versions available under the url.
If you wanna run the whole process only once , use
creates or similar instructions. Ansible will check if the file or directory is there and if it is, skip the step. As an example, let's say you download some tar and extract it somewhere. The
unarchive module takes a parameter called
creates. If you set it to the extracted path, the command will do nothing if that folder is already there. As Ansible can't possible figure out if the version inside the tar is the same as the existing one, it won't unarchive it even though the version changed.
If you want to run a command or a set of commands only if the installed version of a software differs from the target version, things are getting interesting. Let me walk you through that scenario. As an example, I'll use the Dart SDK.
First, we need a command to get the current version installed. For Dart, that's reading the
version file which contains only the version string. Other tools might make this more complicated, e.g. the Google Cloud SDK, which prints a lot of information in addition to the version when running
gcloud --version. I usually use some command line magic like
cut to extract the part I need. Using the
shell module from ansible, I pipe them together to end up with the version being the stdout for that command. We need to use
register to put the output of that task into a variable. The whole task could look like this:
- name: Read the dart version shell: cat /usr/local/lib/dart-sdk/version register: current_dart_version ignore_errors: True changed_when: dart_version != current_dart_version.stdout
As you can see, I ignore errors because the shell command might fail if dart is not installed. I use
changed_when because I like a clean output from Ansible. You can also see a variable named
dart_version which I did not mention yet. It's simply a variable keeping the target dart version.
Next, let us download the Dart SDK if needed:
- name: download dart sdk get_url: dest=/tmp/dartsdk.zip force=yes url=https://storage.googleapis.com/dart-archive/channels/stable/release//sdk/dartsdk-linux-x64-release.zip when: dart_version != current_dart_version.stdout
Again, we use the
dart_version as a target both in the url and for the
when clause which compares the stdout from the above command with our target. The last step is to extract the zip:
- name: extract dart sdk unarchive: copy=no dest=/usr/local/lib src=/tmp/dartsdk.zip when: dart_version != current_dart_version.stdout
The same pattern repeats here as well. We only run the command if the target version is different from the current one. You can use this pattern for all kinds of installation and steps performed after the installation is done.
Even though this seems very much all you can want from a provisioning step, if you have to take care of real servers in production, you might want to be even more cautious. In the example above, if some files are not present in the new SDK, they are not deleted as the extract commands does not take care of this. We could work around this by first deleting the SDK.
In production, it might be valuable to not just replace a version but have two versions installed and switch a symlink or something to change over. This way, it's not possible that a process has access to the software in an unpredictable state.
One tip regarding the version extraction: There are different ways to read the version! You might be able to read the version from some version-file or run the command with a
COMMAND --version parameter. Look for different ways and see if some outputs only the exact version. And if you have to, using some regex with
sed might not be the cleanest way but it does the job.