Own your code, part 4: Laminar CI
In the last post, I talked at a high level about the infrastructure behind my continuous integration and deployment system. In this post, I'm going to dive into the details of the Laminar CI job is the engine that drives the whole system.
Laminar CI is based on a concept of jobs. The docs explain it quite well, but in short each job is a file in the jobs
folder with the file extension run
and a shebang. In my case, I'm using Bash - and I'll continue to do so at regular intervals throughout this series.
Unlike most other setups, the Laminar CI job that we'll be writing here won't actually do any of the actual CI tasks itself - it will simply act as a proxy script to setup & manage the execution of the actual build system - which, in this case, will be the lantern build engine, an engine I wrote to aid me with automating repetitive tasks when working on my University ACWs (Assessed CourseWork).
Every job has it's own workspace, which acts as a common area to store and cache various files across all the runs of that job. Each run of a job also has it's very own private area too - which will be useful later on.
The first step in this proxy script is to extract the parameters of the run that we're supposed to be doing. For me, I store this in a number of environment variables, which are set when queuing the job run from the git post-receive (or web) hook:
Variable |
Example |
Description |
GIT_REPO_NAME |
git-starbeamrainbowlabs-com-sbrl-rhinoreminds |
The safe name of the repository that we're running against, with potentially troublesome characters removed. |
GIT_REF_NAME |
refs/heads/master |
Basically the branch that we're working on. Useful for logging purposes. |
GIT_REPO_URL |
[email protected]:sbrl/rhinoreminds.git |
The URL of the repository that we're running against. |
GIT_COMMIT_REF |
e23b2e0 .... |
The exact commit to check out and build. |
GIT_AUTHOR |
The friendly name of the author that pushed the commit. Useful for logging purposes. |
Before we do anything else, we need to make sure that these variables are defined:
set -e; # Don't allow errors
# Check that all the right variables are present
if [ -z "${GIT_REPO_NAME}" ]; then echo -e "Error: The environment variable GIT_REPO_NAME isn't set." >&2; exit 1; fi
if [ -z "${GIT_REF_NAME}" ]; then echo -e "Error: The environment variable GIT_REF_NAME isn't set." >&2; exit 1; fi
if [ -z "${GIT_REPO_URL}" ]; then echo -e "Error: The environment variable GIT_REPO_URL isn't set." >&2; exit 1; fi
if [ -z "${GIT_COMMIT_REF}" ]; then echo -e "Error: The environment variable GIT_COMMIT_REF isn't set." >&2; exit 1; fi
if [ -z "${GIT_AUTHOR}" ]; then echo -e "Error: The environment variable GIT_AUTHOR isn't set." >&2; exit 1; fi
There are a bunch of other variables that I'm omitting here, since they are dynamically determined by from the build variables. I extract many of these additional variables using regular expressions. For example:
GIT_REF_TYPE="$(regex_match "${GIT_REF_NAME}" 'refs/([a-z]+)')";
GIT_REF_TYPE
is the bit after the refs/
and before the actual branch or tag name. It basically tells us whether we're building against a branch or a tag. That regex_match
function is a utility function that I found in the pure bash bible - which is an excellent resource on various tips and tricks to do common tasks without spawning subprocesses - and therefore obtaining superior performance and lower resource usage. Here it is:
# @source https://github.com/dylanaraps/pure-bash-bible#use-regex-on-a-string
# Usage: regex "string" "regex"
regex_match() {
[[ $1 =~ $2 ]] && printf '%s\n' "${BASH_REMATCH[1]}"
}
Very cool. For completeness, here are the remainder of the secondary environment variables. Many of them aren't actually used directly - instead they are used indirectly by other scripts and lantern build engine tasks that we call from the main Laminar CI job.
if [[ "${GIT_REF_TYPE}" == "tags" ]]; then
GIT_TAG_NAME="$(regex_match "${GIT_REF_NAME}" 'refs/tags/(.*)$')";
fi
# NOTE: These only work with SSH urls.
GIT_REPO_OWNER="$(echo "${GIT_REPO_URL}" | grep -Po '(?<=:)[^/]+(?=/)')";
GIT_REPO_NAME_SHORT="$(echo "${GIT_REPO_URL}" | grep -Po '(?<=/)[^/]+(?=\.git$)')";
GIT_SERVER_DOMAIN="$(echo "${GIT_REPO_URL}" | grep -Po '(?<=@)[^/]+(?=:)')";
GIT_TAG_NAME
is the name of the tag that we're building against - but only if we've been passed a tag as the GIT_REF_TYPE
.
The GIT_SERVER_DOMAIN
is important for sending the status reports to the right place. Gitea supports a status API that we can hook into to report on how we're doing. You can see it in action here on my RhinoReminds repository. Those green ticks are the build status that was reported by the Laminar CI job that we're writing in this post. Unfortunately you won't be able to click on it to see the actual build output, as that is currently protected behind a username and password, since the Laminar CI web interface exposes all the git project I've currently got setup on it - including a number of private ones that I can't share.
Anyway, with all our environment variables in order, it's time to do something with them. Before we do though, we should tell Gitea that we're starting the build process:
send-status-gitea "${GIT_COMMIT_REF}" "pending" "Executing build....";
I haven't yet implemented support for sending notifications to GitHub, but it's on my todo list. In theory it's pretty easy to do - this is why I've got that GIT_SERVER_DOMAIN
variable above in anticipation of this.
That send-status-gitea
function there is another helper script I've written that does what you'd expect - it sends a status message to Gitea. It does this by using the environment variables we deduced earlier (that are also export
ed - though I didn't include that in the abovecode snippet) and curl
.
There's still a bunch of stuff to get through in this post, so I'm going to omit the source of that script from this post for brevity. I've got no particular issue with releasing it though - if you're interested, contact me using the details on my homepage.
Next, we need to set an exit trap. This is a function that will run when the Bash process exits - regardless of whether this was because we finished our work successfully, or otherwise. This can be very useful to make absolutely sure that your script cleans up after itself. In our case, we're only going to be using it to report the build status back to Gitea:
# Runs on exit, no matter what
cleanup() {
original_exit_code="$?";
status="success";
description="Build ${RUN} succeeded in $(human-duration "${SECONDS}").";
if [[ "${original_exit_code}" -ne "0" ]]; then
status="failed";
description="Build failed with exit code ${original_exit_code} after $(human-duration "${SECONDS}")";
fi
send-status-gitea "${GIT_COMMIT_REF}" "${status}" "${description}";
}
trap cleanup EXIT;
Very cool. The RUN
variable there is provided by Laminar CI, and SECONDS
is a bash built-in that tells us the number of seconds that the current Bash process has been running for. human-duration
is yet another helper script because I like nice readable durations in my status messages - not something unreadable like Build 3 failed in 345 seconds
. It's also somewhat verbose - I adapted it from this StackExchange answer.
With that all out of the way, the next item on the list is to work out what job name we're running under. I've chosen git-repo
for the name of the master 'virtual' job - that is to say the one whose entire purpose is to queue the actual job. That's pretty easy, since Laminar gives us an environment variable:
if [ "${JOB}" == "git-repo" ]; then
# ...
fi
If the job name is git-repo
, then we need to queue the actual job name. Since I don't want to have to manually alter the system every time I'm setting up a new repo on my CI system, I've automated the process with symbolic links. The main git-repo
job creates a symbolic link to itself in the name of the repository that it's supposed to be running against, and then queues a new job to run itself under the different job name. This segment takes place nested in the above if statement:
# If the job file doesn't exist, create it
# We create a symlink here because this is a 'smart' job - whose
# behaviour changes dynamically based on the job name.
if [ ! -e "${LAMINAR_HOME}/cfg/jobs/${repo_job_name}.run" ]; then
pushd "${LAMINAR_HOME}/cfg/jobs";
ln -s "git-repo.run" "${repo_job_name}.run";
popd
fi
Once we're sure that the symbolic link is in place, we can queue the virtual copy:
# Queue our new hologram
LAMINAR_REASON="git push by ${GIT_AUTHOR} to ${GIT_REF_NAME}" laminarc queue "${repo_job_name}" GIT_REPO_NAME="${GIT_REPO_NAME}" GIT_REF_NAME="${GIT_REF_NAME}" GIT_REPO_URL="${GIT_REPO_URL}" GIT_COMMIT_REF="${GIT_COMMIT_REF}" GIT_AUTHOR="${GIT_AUTHOR}";
# If we got to here, we queued the hologram successfully
# Clear the trap, because we know that the trap for the hologram will fire
# This avoids sending a 2nd status to Gitea, linking the user to the wrong place
trap - EXIT;
exit 0;
This also ensures that if we make any changes to the main job file, all the copies will get updated automatically too. After all, they are only pointers to the actual job on disk.
Notice that we also clear the trap there before exiting - that's important, since we're queuing a copy of ourselves, we don't want to report the completed status before we've actually finished.
At this point, we can now look at what happens if the job name isn't git-repo
. In this case, we need to do a few things:
- Clone the git repository in question to the shared workspace (if it hasn't been done already)
- Fetch new commits on the shared repository copy
- Check out the right commit
- Copy it to the run-specific directory
- Execute the build script
Additionally, we need to ensure that points #1 to #4 are not done by multiple jobs that are running at the same time, since that would probably confuse things and induce weird and undesirable behaviour. This might happen if we push multiple commits at once, for example - since the git post-receive hook (which I'll be talking about in a future post) queues 1 run per commit.
We can make sure of this by using flock
. It's actually a feature provided by the Linux Kernel, which allows a single process to obtain exclusive access to a resource on disk. Since each Laminar job has it's own workspace as described above, we can abuse this by doing an flock
on the workspace directory. This will ensure that only 1 run per job is accessing the workspace area at once:
# Acquire a lock for this repo
exec 9<"${WORKSPACE}";
flock --exclusive 9;
echo "[${SECONDS}] Lock acquired";
Nice. Next, we need to clone the repository into the shared workspace if we haven't already:
cd "${WORKSPACE}";
# If we haven't already, clone the repository
git_directory="$(echo "${GIT_REPO_URL}" | grep -oP '(?<=/)(.+)(?=.git$)')";
if [ ! -d "${git_directory}" ]; then
echo "[${SECONDS}] Cloning repository";
git clone "${GIT_REPO_URL}";
fi
cd "${git_directory}";
Then, we need to fetch any new commits:
# Pull down any updates that are available
echo "[${SECONDS}] Downloading commits";
git fetch origin;
....and check out the one we're supposed to be building:
# Checkout the commit we're interested in testing
echo "[${SECONDS}] Checking out ${GIT_COMMIT_REF}";
git checkout "${GIT_COMMIT_REF}";
Then, we need to copy the repo to the run-specific directory. This is important, since the run might create new files - and we don't want multiple runs running in the same directory at the same time.
echo "[${SECONDS}] Linking source to run directory";
# Hard-link the repo content to the run directory
# This is important because then we can allow multiple runs of the same repo at the same time without using extra disk space
# -r Recursive mode
# -a Preserve permissions
# -l Hardlink instead of copy
cp -ral ./ "${run_directory}";
# Don't forget the .git directory, .gitattributes, .gitmodules, .gitignore, etc.
# This is required for submodules and other functionality, but likely won't be edited - hence we can hardlink here (I think).
# NOTE: If we see weirdness with multiple runs at a time, then we'll need to do something about this.
cp -ral ./.git* "${run_directory}/.git";
I'm using hard linking here for efficiency - I'm banking on the fact that the build script I call isn't going to modify any existing files. Thinking about it, I should do a git reset --hard
there just in case - though then I'd have all sorts of nasty issues with timing problems.
So far, I haven't had any issues. If I do, then I'll just disable the hard linking and copy instead. This entire script assumes a trusted environment - i.e. it trusts that the code being executed is not malicious. To this end, it's only suitable for personal projects and the like.
For it to be useful in untrusted environments, it would need to avoid hard linking and execute the build script inside a container - e.g. using LXD or Docker.
Moving on, we next need to release that flock
and return to the run-specific directory:
# Go back to the job-specific run directory
cd "${run_directory}";
# Release the lock
exec 9>&- # Close file descriptor 9 and release lock
echo "[${SECONDS}] Lock released";
At this point, we're all set up to run the build script. We need to find it first though. I've currently got 2 standards I'm using across my repositories: build
and build.sh
. This is easy to automate:
build_script="./build";
if [ ! -x "${build_script}" ]; then build_script="./build.sh"; fi
# FUTURE: Add Makefile support here?
if [ ! -x "${build_script}" ]; then
echo "[${SECONDS}] Error: Couldn't find the build script, or it wasn't marked as executable." >&2;
exit 1;
fi
Now that we know where it is, we can execute it. Before we do though, as a little extra I like to run shellcheck over it - since we assume that it's a shell script too (though it might call something that isn't a shell script):
echo "----------------------------------------------------------------";
echo "------------------ Shellcheck of build script ------------------";
set +e; # Allow shellcheck errors - we just warn about them
shellcheck "${build_script}";
set -e;
echo "----------------------------------------------------------------";
I can highly recommend shellcheck - it finds a number of potential issues in both style and syntax that might cause your shell scripts to behave in unexpected ways. I've learnt a bunch about shell scripting and really improved my skills from using it on a regular basis.
Finally, we can now actually execute the build script:
echo "[${SECONDS}] Executing '${build_script} ci'";
nice -n10 ${build_script} ci
I pass the argument ci
here, since the lantern build engine takes task names as arguments on the command line. If it's not a lantern script, then it can be interpreted as a helpful hint as to the environment that it's running in.
I also nice
it to push it into the background, since I actually have my Laminar CI server running on a Raspberry Pi and it's resources are rather limited. I found oddly that I'd lose other essential services (e.g. SSH) if I didn't do this for some reason - since build tasks are usually quite computationally expensive.
That completes the build script. Of course, when the above finishes executing the trap that we set earlier will trigger and the build status reported. I'll include the full script at the bottom of this post.
This was a long post! We've taken a deep dive into the engine that powers my build system. In the next few posts, I'd like to talk about the git post-receive hook I've been mentioning that triggers this job. I'd also like to talk formally about the lantern build engine - what it is, where it came from, and how it works.
Found this interesting? Spotted a mistake? Got a suggestion? Confused about something? Comment below!
#!/usr/bin/env bash
set -e; # Don't allow errors
# Check that all the right variables are present
if [ -z "${GIT_REPO_NAME}" ]; then echo -e "Error: The environment variable GIT_REPO_NAME isn't set." >&2; exit 1; fi
if [ -z "${GIT_REF_NAME}" ]; then echo -e "Error: The environment variable GIT_REF_NAME isn't set." >&2; exit 1; fi
if [ -z "${GIT_REPO_URL}" ]; then echo -e "Error: The environment variable GIT_REPO_URL isn't set." >&2; exit 1; fi
if [ -z "${GIT_COMMIT_REF}" ]; then echo -e "Error: The environment variable GIT_COMMIT_REF isn't set." >&2; exit 1; fi
if [ -z "${GIT_AUTHOR}" ]; then echo -e "Error: The environment variable GIT_AUTHOR isn't set." >&2; exit 1; fi
# It's checked directly anyway
# shellcheck disable=SC1091
source source_regex_match.sh;
GIT_REF_TYPE="$(regex_match "${GIT_REF_NAME}" 'refs/([a-z]+)')";
if [[ "${GIT_REF_TYPE}" == "tags" ]]; then
GIT_TAG_NAME="$(regex_match "${GIT_REF_NAME}" 'refs/tags/(.*)$')";
fi
# NOTE: These only work with SSH urls.
GIT_REPO_OWNER="$(echo "${GIT_REPO_URL}" | grep -Po '(?<=:)[^/]+(?=/)')";
GIT_REPO_NAME_SHORT="$(echo "${GIT_REPO_URL}" | grep -Po '(?<=/)[^/]+(?=\.git$)')";
GIT_SERVER_DOMAIN="$(echo "${GIT_REPO_URL}" | grep -Po '(?<=@)[^/]+(?=:)')";
export GIT_REPO_OWNER GIT_REPO_NAME_SHORT GIT_SERVER_DOMAIN GIT_REF_TYPE GIT_TAG_NAME;
###############################################################################
# Example URL: [email protected]:sbrl/rhinoreminds.git
# Environment variables:
# GIT_REPO_NAME git-starbeamrainbowlabs-com-sbrl-rhinoreminds
# GIT_REF_NAME refs/heads/master, refs/tags/v0.1.1-build7
# GIT_REF_TYPE heads, tags
# Determined dynamically from GIT_REF_NAME.
# GIT_TAG_NAME v0.1.1-build7
# Determined dynamically from GIT_REF_NAME, only set if GIT_REF_TYPE == "tags".
# GIT_REPO_URL [email protected]:sbrl/rhinoreminds.git
# GIT_COMMIT_REF e23b2e0f3c0b9f48effebca24db48d9a3f028a61
# GIT_AUTHOR bob
# Generated:
# GIT_SERVER_DOMAIN git.starbeamrainbowlabs.com
# GIT_REPO_OWNER sbrl
# GIT_REPO_NAME_SHORT rhinoreminds
# GIT_RUN_SOURCE github
# Not always set. If not set then assume git.starbeamrainbowlabs.com
send-status-gitea "${GIT_COMMIT_REF}" "pending" "Executing build....";
# Runs on exit, no matter what
cleanup() {
original_exit_code="$?";
status="success";
description="Build ${RUN} succeeded in $(human-duration "${SECONDS}").";
if [[ "${original_exit_code}" -ne "0" ]]; then
status="failed";
description="Build failed with exit code ${original_exit_code} after $(human-duration "${SECONDS}")";
fi
send-status-gitea "${GIT_COMMIT_REF}" "${status}" "${description}";
}
trap cleanup EXIT;
###############################################################################
repo_job_name="$(echo "${GIT_REPO_NAME}" | tr '/' '--')";
if [ "${JOB}" == "git-repo" ]; then
# If the job file doesn't exist, create it
# We create a symlink here because this is a 'smart' job - whose
# behaviour changes dynamically based on the job name.
if [ ! -e "${LAMINAR_HOME}/cfg/jobs/${repo_job_name}.run" ]; then
pushd "${LAMINAR_HOME}/cfg/jobs";
ln -s "git-repo.run" "${repo_job_name}.run";
popd
fi
# Queue our new hologram
LAMINAR_REASON="git push by ${GIT_AUTHOR} to ${GIT_REF_NAME}" laminarc queue "${repo_job_name}" GIT_REPO_NAME="${GIT_REPO_NAME}" GIT_REF_NAME="${GIT_REF_NAME}" GIT_REPO_URL="${GIT_REPO_URL}" GIT_COMMIT_REF="${GIT_COMMIT_REF}" GIT_AUTHOR="${GIT_AUTHOR}";
# If we got to here, we queued the hologram successfully
# Clear the trap, because we know that the trap for the hologram will fire
# This avoids sending a 2nd status to Gitea, linking the user to the wrong place
trap - EXIT;
exit 0;
fi
# We're running in hologram mode!
# Remember the run directory - we'll need it later
run_directory="$(pwd)";
# Important directories:
# $WORKSPACE Shared between all runs of a job
# $run_directory The initial directory a run lands in. Empty and run-specific.
# $ARCHIVE Also run-speicfic, but the contents is persisted after the run ends
# Acquire a lock for this repo
#laminarc lock "${JOB}-workspace";
exec 9<"${WORKSPACE}";
flock --exclusive 9;
###############################################################################
# No need to allow errors here, because the lock will automagically be released
# if the process crashes, as that'll close the file description anyway :P
echo "[${SECONDS}] Lock acquired";
cd "${WORKSPACE}";
# If we haven't already, clone the repository
git_directory="$(echo "${GIT_REPO_URL}" | grep -oP '(?<=/)(.+)(?=.git$)')";
if [ ! -d "${git_directory}" ]; then
echo "[${SECONDS}] Cloning repository";
git clone "${GIT_REPO_URL}";
fi
cd "${git_directory}";
# Pull down any updates that are available
echo "[${SECONDS}] Downloading commits";
git fetch origin;
# Checkout the commit we're interested in testing
echo "[${SECONDS}] Checking out ${GIT_COMMIT_REF}";
git checkout "${GIT_COMMIT_REF}";
echo "[${SECONDS}] Linking source to run directory";
# Hard-link the repo content to the run directory
# This is important because then we can allow multiple runs of the same repo at the same time without using extra disk space
# -r Recursive mode
# -a Preserve permissions
# -l Hardlink instead of copy
cp -ral ./ "${run_directory}";
# Don't forget the .git directory, .gitattributes, .gitmodules, .gitignore, etc.
# This is required for submodules and other functionality, but likely won't be edited - hence we can hardlink here (I think).
# NOTE: If we see weirdness with multiple runs at a time, then we'll need to do something about this.
cp -ral ./.git* "${run_directory}/.git";
echo "[${SECONDS}] done";
# Go back to the job-specific run directory
cd "${run_directory}";
###############################################################################
# Release the lock
exec 9>&- # Close file descriptor 9 and release lock
#laminarc release "${JOB}-workspace";
echo "[${SECONDS}] Lock released";
echo "[${SECONDS}] Finding build script";
build_script="./build";
if [ ! -x "${build_script}" ]; then build_script="./build.sh"; fi
# FUTURE: Add Makefile support here?
if [ ! -x "${build_script}" ]; then
echo "[${SECONDS}] Error: Couldn't find the build script, or it wasn't marked as executable." >&2;
exit 1;
fi
echo "[${SECONDS}] Executing '${build_script} ci'";
echo "----------------------------------------------------------------";
echo "------------------ Shellcheck of build script ------------------";
set +e; # Allow shellcheck errors - we just warn about them
shellcheck "${build_script}";
set -e;
echo "----------------------------------------------------------------";
nice -n10 ${build_script} ci