ECR/Docker Image for JIT Runner

You are viewing article number 4 of 8 in the series GitHub just-in-time (JIT) self-hosted Runners on AWS Fargate

Our image will be derived from an ubuntu:jammy base, for target architecture type, linux/amd64.

After building the image, we’ll push it to an ECR repository.

Image Artifacts

Create a location to store the image files:

$ mkdir $HOME/gha-jit-fargate-runner

Add the files required.

$HOME/gha-jit-fargate-runner/Dockerfile
FROM ubuntu:jammy

ENV LC_ALL=C
ENV DEBIAN_FRONTEND=noninteractive
ENV DEBCONF_NONINTERACTIVE_SEEN=true
ENV RUNNER_TOOL_CACHE=/opt/hostedtoolcache
ENV AGENT_TOOLSDIRECTORY=${RUNNER_TOOL_CACHE}
ARG RUNNER_USER
ARG RUNNER_USER_UID=1000
ARG RUNNER_USER_GID=$RUNNER_USER_UID
ARG RUNNER_VERSION
ARG DUMB_INIT_VERSION=1.2.5
ENV RUNNER_USER=${RUNNER_USER}
ENV RUNNER_HOME=/home/${RUNNER_USER}/actions_runner
ENV HOME=/home/${RUNNER_USER}
ENV TZ="UTC"
ENV RUNNER_VERSION=${RUNNER_VERSION}
ENV TARGETPLATFORM=linux/amd64
ENV RUNNER_ARCH=x64
ENV RUNNER_MANUALLY_TRAP_SIG=1

RUN apt-get update -y \
  && apt-get upgrade -y \
  && apt-get install -y --no-install-recommends \
  apt-utils \
  curl \
  awscli \
  tzdata \
  jq \
  ca-certificates \
  apt-transport-https \
  software-properties-common \
  wget \
  git \
  gnupg \
  zip \
  unzip \
  lsb-release \
  util-linux \
  && apt-get autoremove -y \
  && apt-get autoclean -y \
  && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Download latest git-lfs version
RUN curl -s https://packagecloud.io/install/repositories/github/git-lfs/script.deb.sh | bash && \
  apt-get install -y --no-install-recommends git-lfs

RUN if [ "$(getent group ${RUNNER_USER_GID} | cut -d: -f3)" = "${RUNNER_USER_GID}" ] ; then \
  groupdel $(getent group ${RUNNER_USER_GID} | cut -d: -f1) ; \
  fi \
  && addgroup --system --gid ${RUNNER_USER_GID} ${RUNNER_USER}

RUN adduser --system --home ${HOME} --uid ${RUNNER_USER_GID} --shell /bin/bash --gecos "" --gid ${RUNNER_USER_GID} --disabled-password ${RUNNER_USER}

# https://github.com/actions/setup-python/issues/459#issuecomment-1182946401
RUN mkdir -p ${RUNNER_TOOL_CACHE} \
  && chown ${RUNNER_USER}:${RUNNER_USER} ${RUNNER_TOOL_CACHE} \
  && chmod g+rwx ${RUNNER_TOOL_CACHE}

RUN LSB_RELEASE=$(lsb_release -rs) \
  && wget -q https://packages.microsoft.com/config/ubuntu/$LSB_RELEASE/packages-microsoft-prod.deb \
  && dpkg -i packages-microsoft-prod.deb \
  && rm packages-microsoft-prod.deb \
  && apt-get update -y \
  && apt-get upgrade -y \
  && apt-get install -y powershell \
  && apt-get autoremove -y \
  && apt-get autoclean -y \
  && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

RUN export ARCH=$(dpkg --print-architecture) \
  && if [ "$ARCH" = "arm64" ]; then export ARCH=aarch64 ; fi \
  && if [ "$ARCH" = "amd64" ] || [ "$ARCH" = "i386" ]; then export ARCH=x86_64 ; fi \
  && curl -fLo /usr/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v${DUMB_INIT_VERSION}/dumb-init_${DUMB_INIT_VERSION}_${ARCH} \
  && chmod +x /usr/bin/dumb-init

RUN export ARCH=$(dpkg --print-architecture) \
    && if [ "$ARCH" = "amd64" ] || [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "i386" ]; then export ARCH=x64 ; fi \
    && mkdir -p "$AGENT_TOOLSDIRECTORY" \
    && mkdir -p "$RUNNER_HOME" \
    && cd "$RUNNER_HOME" \
    && export runner_download_url=$(curl -s -L \
       -H "Accept: application/vnd.github+json" \
       -H "X-GitHub-Api-Version: 2022-11-28" https://api.github.com/repos/actions/runner/releases/latest | \
       jq -r ".assets[]|select(.name | startswith(\"actions-runner-linux-$ARCH\")).browser_download_url") \
    && curl -fLo runner.tar.gz ${runner_download_url} \
    && tar xzf ./runner.tar.gz \
    && rm -f runner.tar.gz \
    && ./bin/installdependencies.sh \
    && apt-get install -y libyaml-dev \
    && apt-get autoremove -y \
    && apt-get autoclean -y \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
    && chown -R ${RUNNER_USER}:${RUNNER_USER} \
    "$AGENT_TOOLSDIRECTORY" \
    "$RUNNER_HOME" \
    && chmod -R g+rwx ${RUNNER_TOOL_CACHE} "$RUNNER_HOME"

RUN curl -fsSLO https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip \
  && unzip awscli-exe-linux-x86_64.zip \
  && ./aws/install -i /usr/local/aws -b /usr/local/bin \
  && rm awscli-exe-linux-x86_64.zip

RUN arch="$(dpkg --print-architecture)" \
  && cli_latest_version="$(curl -s -L -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" https://api.github.com/repos/cli/cli/releases/latest | jq -r '.tag_name' | cut -c 2-)" \
  && wget "https://github.com/cli/cli/releases/download/v${cli_latest_version}/gh_${cli_latest_version}_linux_${arch}.deb" -O gh_${cli_latest_version}_linux_${arch}.deb \
  && dpkg -i "gh_${cli_latest_version}_linux_${arch}.deb" \
  && rm "gh_${cli_latest_version}_linux_${arch}.deb"

COPY ./entrypoint.sh ./logger.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh /usr/bin/logger.sh

RUN mkdir -p ${RUNNER_HOME} \
    && chown -R ${RUNNER_USER}:${RUNNER_USER} ${RUNNER_HOME} ${HOME} $AGENT_TOOLSDIRECTORY

RUN echo "ImageOS=ubuntu-jammy" >> /etc/environment

USER ${RUNNER_USER}

ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["bash", "-c", "exec /usr/bin/entrypoint.sh"]
$HOME/gha-jit-fargate-runner/entrypoint.sh
#!/usr/bin/env bash
source logger.sh
RUNNER_USER=$(whoami)
RUNNER_HOME=${RUNNER_HOME:-/home/${RUNNER_USER}/actions-runner}
RUNNER_ARCH=${RUNNER_ARCH:-x64}
RUNNER_CONFIG_RETRIES=${RUNNER_CONFIG_RETRIES:-10}
RUNNER_CONFIG_RETRY_INTERVAL_SECONDS=${RUNNER_CONFIG_RETRY_INTERVAL_SECONDS:-10}
SQS_QUEUE_NAME="${SQS_QUEUE_NAME:-git-actions-wf-job-queue}"

aws_acct=$(aws sts get-caller-identity --query Account --output text)
aws_region=$(aws configure get region)

QUEUE_URL="https://sqs.$aws_region.amazonaws.com/$aws_acct/$SQS_QUEUE_NAME"

trap 'kill -INT -$PID;
echo "Exiting runner";
rm -fr ${RUNNER_HOME}/.runner;
${RUNNER_HOME}/config.sh remove' INT TERM

MSG=$(aws sqs receive-message --queue-url "$QUEUE_URL")

rc=$?

if [[ -n "$MSG" ]] && [[ $rc -eq 0 ]]; then
  labels=$(echo "$MSG" | jq -r '.Messages[]| .ReceiptHandle' |
    (xargs -I {} aws sqs delete-message --queue-url "$QUEUE_URL" --receipt-handle {}) &&
    echo "$MSG" | jq -c '.Messages[].Body|fromjson|.detail.workflow_job.labels')

  log.notice "Received runner labels ${labels}."
  sqs_queue_msg_id=$(echo "$MSG" | jq -r '.Messages[]|[.MessageId]|("SQS Queue Message Id: "+.[])')
  log.notice "Processing ${sqs_queue_msg_id}."
else
  echo "Error: problem with reading sqs queue/labels"
  exit 1
fi

job_run_ids=$(echo "$MSG" | jq -r '.Messages[]|.Body|fromjson|
  [("job_id" + ": " + (.detail.workflow_job.id|tostring)),
  ("run_id" + ": " + (.detail.workflow_job.run_id|tostring))]|join(", ")')

log.notice "Processing for ${job_run_ids}."

job_id=$(echo "$MSG" | jq -r '.Messages[]|.Body|fromjson|.detail.workflow_job.id|tostring')

if [ -n "${STARTUP_DELAY_IN_SECONDS}" ]; then
  log.notice "Delaying startup by ${STARTUP_DELAY_IN_SECONDS} seconds"
  sleep "${STARTUP_DELAY_IN_SECONDS}"
fi

github_api_pfx="https://api.github.com"

if [[ -n "${REPOSITORY}" ]]; then
  rest_api_ep="${github_api_pfx}/repos/${ORGANIZATION}/${REPOSITORY}"
fi

if [[ -n "${ORGANIZATION}" ]]; then
  rest_api_ep="${github_api_pfx}/orgs/${ORGANIZATION}"
fi

if [[ -n "${ENTERPRISE}" ]]; then
  rest_api_ep="${github_api_pfx}/enterprises/${ENTERPRISE}"
fi

rest_api_ep_runners_pfx=${rest_api_ep}/actions/runners

log.notice "Will use GitHub Rest API JIT config prefix ${rest_api_ep_runners_pfx}."

runner_version_maintenance() {

  # Check the official actions/runner public repository for the latest release version tag.
  # Strictly speaking, an ACCESS_TOKEN is not required for for "curling" the runner agent public repository,
  # however, without a token, we are more likely to hit the GitHub API rate limit
  remote_ver=$(curl -s -L \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "X-GitHub-Api-Version: 2022-11-28" https://api.github.com/repos/actions/runner/releases/latest |
    jq -r '.tag_name' | cut -c 2-)

  current_ver="$("$RUNNER_HOME"/config.sh --version)"

  log.notice "Runner agent version bundled in our image is ${current_ver}, latest official release version is ${remote_ver}"

  if [ "${current_ver}" != "${remote_ver}" ]; then
    log.notice "Upgrading runner agent from version ${current_ver}, to latest official release ${remote_ver}"
    rm -fr "$RUNNER_HOME/{"*",".*"}"
    runner_download_url=$(curl -s -L \
      -H "Accept: application/vnd.github+json" \
      -H "Authorization: Bearer ${ACCESS_TOKEN}" \
      -H "X-GitHub-Api-Version: 2022-11-28" https://api.github.com/repos/actions/runner/releases/latest |
      jq -r ".assets[]|select(.name | startswith(\"actions-runner-linux-$RUNNER_ARCH\")).browser_download_url")
    curl -fLo "$RUNNER_HOME"/runner.tar.gz "$runner_download_url"
    tar xzf "$RUNNER_HOME"/runner.tar.gz -C "$RUNNER_HOME"
    rm -f "$RUNNER_HOME"/runner.tar.gz
    "$RUNNER_HOME"/bin/installdependencies.sh
    log.notice "Unpacked upgraded runner from version ${current_ver} to ${remote_ver}"
    export RUNNER_VERSION="${remote_ver}"
  else
    log.notice "Installed and latest available runner versions are identical:- installed: ${current_ver}, latest release: ${remote_ver} ... update not required."
  fi
}

if ! cd "${RUNNER_HOME}"; then
  log.notice "Runner home dir ${RUNNER_HOME} is missing, creating it..."
  mkdir -p "${RUNNER_HOME}"
  chown -R "${RUNNER_USER}":"${RUNNER_USER}" "$RUNNER_HOME"
else
  rm -fr "$RUNNER_HOME/{"*",".*"}"
  runner_version_maintenance
fi

log.notice "Registering..."

retries_left=${RUNNER_CONFIG_RETRIES}

while [[ ${retries_left} -gt 0 ]]; do
  log.debug 'Configuring the runner.'
  jit_json_parms="{\"name\":\"${job_id}\",\"runner_group_id\":1,\"labels\":${labels},\"work_folder\":\"_work\"}"

  data=$(echo -en "$jit_json_parms" | jq -c '.')

  log.notice "Will generate JIT runner config using the following options:"
  log.notice "${data}"

  jit_runner_config=$(curl -sL -X POST \
    -H "Accept: application/vnd.github+json" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "X-GitHub-Api-Version: 2022-11-28" "${rest_api_ep_runners_pfx}"/generate-jitconfig \
    -d "${data}")

  rc=$?

  if [[ $rc -eq 0 ]]; then
    encoded_jit_config=$(echo -en "$jit_runner_config" | jq -cr '.encoded_jit_config')
    log.debug 'Runner successfully configured.'
    break
  fi

  log.debug 'Configuration failed. Retrying'
  retries_left=$((RUNNER_CONFIG_RETRIES - 1))

  log.debug "Number of retries left before giving up -> ${retries_left} "
  sleep "${RUNNER_CONFIG_RETRY_INTERVAL_SECONDS}"
done

# unset the following to prevent them from being displayed/echoed from the runner environment
unset REPOSITORY ACCESS_TOKEN ORGANIZATION ENTERPRISE STARTUP_DELAY_IN_SECONDS jit_runner_config

log.notice "Spawning runner..."

mapfile -t env </etc/environment
env -- "${env[@]}" ./run.sh --jitconfig "${encoded_jit_config}" &

PID=$!
wait $PID
returnCode=$?

log.notice "Runner background process completed with retun code: $returnCode."
log.notice "Exiting with return code: $returnCode."

exit $returnCode
$HOME/gha-jit-fargate-runner/logger.sh
#!/usr/bin/env bash

##########################################################################################
# This script was sourced from :
#    - https://github.com/actions/actions-runner-controller/blob/master/runner/logger.sh
##########################################################################################

# We are not using `set -Eeuo pipefail` here because this file is sourced by
# other scripts that might not be ready for a strict Bash setup. The functions
# in this file do not require it, because they are not handling signals, have
# no external calls that can fail (printf as well as date failures are ignored),
# are not using any variables that need to be set, and are not using any pipes.

# This logger implementation can be replaced with another logger implementation
# by placing a script called `logger.sh` in `/usr/local/bin` of the image. The
# only requirement for the script is that it defines the following functions:
#
# - `log.debug`
# - `log.notice`
# - `log.warning`
# - `log.error`
# - `log.success`
#
# Each function **MUST** accept an arbitrary amount of arguments that make up
# the (unstructured) logging message.
#
# Additionally the following environment variables **SHOULD** be supported to
# disable their corresponding log entries, the value of the variables **MUST**
# not matter the mere fact that they are set is all that matters:
#
# - `LOG_DEBUG_DISABLED`
# - `LOG_NOTICE_DISABLED`
# - `LOG_WARNING_DISABLED`
# - `LOG_ERROR_DISABLED`
# - `LOG_SUCCESS_DISABLED`

# The log format is constructed in a way that it can easily be parsed with
# standard tools and simple string manipulations; pattern and example:
#
#     YYYY-MM-DD hh:mm:ss.SSS  $level --- $message
#     2022-03-19 10:01:23.172  NOTICE --- example message
#
# This function is an implementation detail and **MUST NOT** be called from
# outside this script (which is possible if the file is sourced).
__log() {
  local color instant level

  color=${1:?missing required <color> argument}
  shift

  level=${FUNCNAME[1]} # `main` if called from top-level
  level=${level#log.}  # substring after `log.`
  level=${level^^}     # UPPERCASE

  if [[ ! -v "LOG_${level}_DISABLED" ]]; then
    instant=$(date '+%F %T.%-3N' 2>/dev/null || :)

    # https://no-color.org/
    if [[ -v NO_COLOR ]]; then
      printf -- '%s  %s --- %s\n' "$instant" "$level" "$*" 1>&2 || :
    else
      printf -- '\033[0;%dm%s  %s --- %s\033[0m\n' "$color" "$instant" "$level" "$*" 1>&2 || :
    fi
  fi
}

# To log with a dynamic level use standard Bash capabilities:
#
#     level=notice
#     command || level=error
#     "log.$level" message
#
# @formatter:off
log.debug() { __log 37 "$@"; }   # white
log.notice() { __log 34 "$@"; }  # blue
log.warning() { __log 33 "$@"; } # yellow
log.error() { __log 31 "$@"; }   # red
log.success() { __log 32 "$@"; } # green
# @formatter:on

Build and Tag

Assuming the following ECR repository has been created,

<aws-acct-id>.dkr.ecr.<region>.amazonaws.com/gha-jit-fargate-runner

build and tag the image for uploading to the ECR:

$ cd $HOME/gha-jit-fargate-runner
$ docker build -t <aws-acct-id>.dkr.ecr.<region>.amazonaws.com/gha-jit-fargate-runner:latest \
       --build-arg RUNNER_USER="ubuntu" \
       --build-arg RUNNER_VERSION="$(curl -s -L \
        -H "Accept: application/vnd.github+json" \
        -H "X-GitHub-Api-Version: 2022-11-28" https://api.github.com/repos/actions/runner/releases/latest | \
        jq -r '.tag_name' | cut -c 2-)" .

Push to ECR

Authenticate to target registry:

$ aws ecr get-login-password --region <region> | docker login --username AWS --password-stdin <aws-acct-id>.dkr.ecr.<region>.amazonaws.com

Push the image to ECR:

$ docker push <aws-acct-id>.dkr.ecr.<region>.amazonaws.com/gha-jit-fargate-runner:latest
Series Navigation<< Standard SQS QueueECS Fargate Task >>