Version control for configuration files

Please keep in mind that this post is about 5 years old.
Technology may have changed in the meantime.

Yesterday I wrote a post about setting up a Subversion server.
Subversion is a great tool for controlling versions of collections of files that are contained in a single root directory (an entire website, development of a software application, etc.). But it’s not the perfect tool for controlling the versions of single files.
So, after yesterday’s post, I now feel somewhat obliged to also document the system I use for controlling the versions of my configuration files.

Yes, I have all my configuration files under version control. I do this for 2 reasons:

  • I can easily go back to a previous version if changed settings break stuff
  • I can look up when and why I made certain changes

To ease this process, I wrote a wrapper around my text editor. Which in my case is vim, but could be any command line text editor; and maybe even graphical editors, I haven’t tested that.
You will find this Bash script below, but before you download and start using it, read these next few lines about it’s workings.

Make sure the RCS package is installed. If you can’t find it in your system’s package system, download it from the GNU website. With the last update in January 2015 it’s an old package; let’s hope it doesn’t die, because I haven’t been able to find an alternative.
Copy the script below, and save it as /usr/local/bin/vircs. It should be executable by everybody, but writable only by root:

# chmod 755 /usr/local/bin/vircs

From now on, the command you execute to edit configuration files is vircs, instead of vim, emacs, nano, or whatever you’ve been using; e.g.:

# vircs /etc/hosts

(Make sure the EDITOR environment variable is set to your favorite editor; vircs will default to vim if EDITOR is not set.)

This will create a file with the same name and suffix ‘,v‘ (‘comma-vee‘), which contains the version information; e.g. ‘/etc/hosts,v‘.

In some cases, this may be a problem. The configuration file for sudo/etc/sudoers — for example, contains the line

#includedir /etc/sudoers.d

which makes sudo include every file in the specified directory, including any files with a ‘,v’ suffix. But since sudo doesn’t recognize the RCS file format, it will then complain. Even though this is clearly a bug in Sudo, it is a reality we have to deal with.
In cases like this, create a sub-directory named ‘RCS‘ (yes, all capitals) before editing the file, and rcs will automatically store the version file in that directory.
If you’ve already created the version file, and then discover that it creates a problem (because a service won’t restart, for example), create the sub-directory and move the version file into it. The service should now restart without complaining, and rcs will automatically find the version file when you re-edit the configuration file.
(BTW: cronie will even complain about this new subdirectory, which is even worse. But since it runs without any problems after having complained, we’ll just let it sob.)

The file you are editing is supposed to exist before you start editing, so if you want to check in a file that doesn’t exist yet, create it first:

$ touch ~/groceries.txt
$ vircs ~/groceries.txt

Obviously, vircs is not limited to configuration files, but can be used for all text-based files (text, HTML, JSON, etc.).

When you bring a file under version control (you edit the file for the first time using Vircs), the script will ask you for a short description of the file. This description will be displayed in the output of rlog. The size of this description is limited to 1 line; hitting [Enter] will mark the end of the description.

Each time you save a file, you are asked to add a short log message. This message is also displayed in the output of rlog, and it’s size is also limited to 1 line.

And that concludes what I wanted to say beforehand. Here’s the script.
Have fun!

(If the script is too wide for your screen, just click on it and then use your arrow keys to scroll right and left.)

#!/usr/bin/env bash

# This is vircs.
#
# Vircs is a simple wrapper around `rcs ci', `rcs co' and a text editor; this is
# probably the millionth of its kind.

# For more info, see
# https://www.ohreally.nl/2019/03/31/version-control-for-configuration-files/

################################################################################

# Copyright (c) 2018 Rob La Lau 

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

################################################################################

# Dependencies.
DEREF=`which realpath || which readlink` || {
	echo "realpath or readlink command not found"
	exit 1
}
RCS=`which rcs` || {
	echo "rcs command not found"
	exit 1
}
CI="${RCS} ci"
CO="${RCS} co"
DIFF="${RCS} diff"
CLEAN="${RCS} clean"
CHGRP=`which chgrp` || {
	echo "chgrp command not found"
	exit 1
}
CHMOD=`which chmod` || {
	echo "chmod command not found"
	exit 1
}
CHOWN=`which chown` || {
	echo "chown command not found"
	exit 1
}
FILE=`which file` || {
	echo "file command not found"
	exit 1
}
RLOG=`which rlog` || {
	echo "rlog command not found"
	exit 1
}
RM=`which rm` || {
	echo "rm command not found"
	exit 1
}
STAT=`which stat` || {
	echo "stat command not found"
	exit 1
}

# Can't do anything without an editor.
[ -z "${EDITOR}" ] && {
	# Real sysadmins use vim. ;)
	echo "Environment variable EDITOR not set; we'll use vim."
	EDITOR=`which vim` || {
		echo "vim not found"
		exit 1
	}
}

# File to edit.
file=$@
[ -z "${file}" ] && {
	echo "Usage: $0 file"
	exit 1
}
file=$("${DEREF}" "${file}")

# File exists?
[ ! -e "${file}" ] && {
	echo "File ${file} does not exist. Please create it before continuing."
	echo "If an RCS file exists, you can (re)create the original by executing"
	echo " ${CO} -u ${file},v"
	echo "Remember to set the correct permissions using 'chown', 'chgrp' and 'chmod'."
	exit 1
}

# Bail out if file ends in ',v'.
[ "${file##*,}" = "v" ] && {
	echo "You specified the RCS file. Please specify the working file."
	echo "If you do not have a working file, you can (re)create it by executing"
	echo " ${CO} -u ${file}"
	echo "Remember to set the correct permissions using 'chown', 'chgrp' and 'chmod'."
	exit 1
}

# Acceptable mime type?
mimetype=`"${FILE}" --brief --dereference --mime-type "${file}"`
[ "${mimetype%%/*}" != "text" -a "${mimetype}" != "inode/x-empty" ] && {
	echo "File ${file} is of type ${mimetype} and cannot be edited by $0."
	exit 1
}

# If the file is checked out locked,
# this command returns the name of the RCS file.
# If it isn't, the empty string is returned.
rcsfile=$("${RLOG}" -L -R "${file}")
if [ "${rcsfile}" = "${file},v" ]; then
	echo "${file} is locked by RCS. Undo this first."
	exit 1
fi

# RCS ignores file owner, group and permissions.
# We don't, as this could break services or applications.
if [ "`uname`" = "Linux" ]; then
	user=`"${STAT}" -c %u "${file}"`
	group=`"${STAT}" -c %g "${file}"`
	rights=`"${STAT}" -c %a "${file}"`
else
	user=`"${STAT}" -f %Su "${file}"`
	group=`"${STAT}" -f %Sg "${file}"`
	rights=`"${STAT}" -f %OLp "${file}"`
fi

# Name of author, for log messages.
if [ -n "${SUDO_USER}" ]; then
	# Using `sudo'.
	author=${SUDO_USER}
elif [ "${USER}" = "root" -a -n "${MAIL}" ]; then
	# When using `su', some of the user's environment is retained (but not when using `su -' !).
	# Obviously, we're making a dangerous assumption here (but for me it works).
	author=${MAIL##*/}
else
	# Fall back to ${USER}.
	author=${USER}
fi

# Is file under version control?
# If so, are there any differences?
diff=`${DIFF} "${file}" 2> /dev/null`
retval=$?
# rcsdiff may have 1 of 3 return values:
# 0 : no differences found
# 1 : differences found
# 2 : rcs file or working file not found
if [ ${retval} -eq 2 ]; then
	# Initial checkin.
	echo "File ${file} is not yet under version control."
	echo "Let's check it in right away."
	echo "Please give a short (1 line) description of the file."
	echo "E.g.:"
	echo " Apache config for virtual host www.example.com"
	echo "or"
	echo " Postfix mail aliases for host `hostname`"
	echo "(This is not the log message!)"
	read -p "Description: " descr
	${CI} -i -u -w${author} -t-"${descr}" -m"Initial checkin" ${file}
	"${CHOWN}" "${user}" "${file}"
	"${CHGRP}" "${group}" "${file}"
	"${CHMOD}" "${rights}" "${file}"
elif [ ${retval} -eq 1 ]; then
	echo "There is a difference between ${file} and the last version in it's RCS file."
	echo "Please verify and correct this before continuing."
	echo
	echo "The approximate steps for correction would be (adjust to system and taste):"
	echo " mv '${file}' '${file}.changed'"
	echo " ${CO} -l '${file}'"
	echo " diff '${file}' '${file}.changed'"
	echo " # incorporate changes to be kept into '${file}'"
	echo " ${CI} -u -w${author} -m'Checking in old changes.' '${file}'"
	echo " rm '${file}.changed'"
	echo "(Do not simply copy the above, but make sure you understand what it does.)"
	echo
	echo "After correction, make sure the file attributes for the working file are as follows:"
	echo "User: ${user}"
	echo "Group: ${group}"
	echo "Rights: ${rights}"
	exit 1
fi

# We can now safely delete the original (to keep co from complaining).
"${RM}" -f "${file}" || exit 1

# Checkout and lock.
${CO} -l "${file}"

# Edit.
"${EDITOR}" "${file}"

# Compare.
diff=`${DIFF} -q "${file}"`

if [ -n "${diff}" ]; then
	# Working file has changed.
	# Checkin (and checkout again, unlocked).
	echo "Please give a short (1 line) log message for this change."
	read -p "Log message: " msg
	${CI} -u -w${author} -m"${msg}" "${file}"
	echo "New version of ${file} saved."
else
	# Working file has not changed.
	# Remove unchanged locked working file, and checkout unlocked.
	${CLEAN} -u "${file}"
	${CO} -u "${file}"
	echo "Changes to ${file} cancelled."
fi

# Correct file attributes.
"${CHOWN}" "${user}" "${file}"
"${CHGRP}" "${group}" "${file}"
"${CHMOD}" "${rights}" "${file}"

# Done

To make full use of the potential of RCS, have a look at it’s documentation.

This script can also be downloaded from my Github account.

Changelog

  • 2021-04-02
    Added the realpath/readlink command to make sure we’re not editing a symlink, which would create a new RCS file in an unexpected place.
  • 2020-12-19
    Moved the -w flag for ci from the definition of the ${author} variable to the execution of the command. It makes more sense this way.
  • 2020-12-16
    Added FreeBSD `stat‘.
  • 2020-12-16
    Added error messages.
  • 2019-03-31
    Initial publication.

REPUBLISHING TERMS

You may republish this article online or in print under our Creative Commons license. You may not edit or shorten the text, you must attribute the article to OhReally.nl and you must include the author’s name in your republication.

If you have any questions, please email rob@ohreally.nl

License

Creative Commons License AttributionCreative Commons Attribution
Version control for configuration files