A request I’ve seen from Concourse users every so often is that they want a way for a Concourse Job to stop what it’s doing and wait for approval from a human. They want what I call a “manual approval” step in their jobs.

A more concrete example that I’ve seen is when users are running terraform apply in their pipelines. They usually end up wanting a Concourse Job that looks like:

  1. Run terraform plan
  2. Have human review and approve the plan
  3. Job continues running and runs terraform apply

Steps 1 and 3 are obvious to do for anyone that’s written a Concourse pipeline. Step two, the “manual approval” step is less obvious. There are many ways such a step could be crafted. This is how I decided to solve it since it only requires a small bash script.

#!/usr/bin/env bash

set -euo pipefail

echo -n "waiting for manual approval..."
while true; do
    sleep 5
    if [[ -f /tmp/approved ]]; then
        break
    fi
    echo -n "."
done

To approve the job when it gets to this step I’d run the following from my local terminal:

fly -t ci i -u BUILD_URL --step manual-approval touch /tmp/approved

If I want to reject the job then I simply hit the big red cancel button in the build logs page in the Concourse web UI.

More Bells and Whistles

Now this script is super simple. One thing you’ll probably want to change is to have the step timeout after waiting some period of time. Let’s modify the while loop condition to timeout after 10mins.

#!/usr/bin/env bash

set -euo pipefail

timeout=$(($EPOCHSECONDS+600))
echo -n "waiting for manual approval..."
while [[ ${$EPOCHSECONDS} -lt ${timeout} ]]; do
    sleep 5
    if [[ -f /tmp/approved ]]; then
        break
    fi
    echo -n "."
done

if [[ ! -f /tmp/approved ]]; then
    echo "Approval timeout reached. Aborting job."
    exit 1
fi

We’ve got a few changes here. Let’s go through them.

First is the usage of Bash’s $EPOCHSECONDS variable. $EPOCHSECONDS returns the number of seconds since the Unix Epoch. We take the value of $EPOCHSECONDS and add 10 minutes in seconds to get the time in the future of our timeout. Also note two sets of parentheses are used. This is how you do math in Bash apparently!

The last addition to discuss is the second if [[ !-f /tmp/approved ]]; after the while loop. The second if is necessary to check why we exited the while loop. If the step was approved, in which case /tmp/approved should exist, then the script can exit normally. If the file does not exist then we can assume the timeout was reached and fail the step, and subsequently the Concourse Job.

A final change we could make is to parameterize our timeout value. We can set the default timeout value in the Concourse Task Configuration. Pipeline authors can override the timeout in their task.params when adding this step to their job plan.

platform: linux

inputs:
- name: repo

params:
  APPROVAL_TIMEOUT: 600 #default of 10mins

run:
  path: repo/tasks/manual-approval/run.sh

Our run.sh:

#!/usr/bin/env bash

set -euo pipefail

timeout=$((EPOCHSECONDS+APPROVAL_TIMEOUT))
echo -n "waiting for manual approval..."
while [[ ${EPOCHSECONDS} -lt ${timeout} ]]; do
    sleep 5
    if [[ -f /tmp/approved ]]; then
        break
    fi
    echo -n "."
done

if [[ ! -f /tmp/approved ]]; then
    echo "Approval timeout reached. Aborting job."
    exit 1
fi

That’s how I do manual approval steps in my pipelines. There are of course many different ways you could do this, probably with a better UX for whoever the end user is that needs to approve the job. I found this is the simplest way to add this kind of step without pulling in a bunch of extra tooling into my stack though.