565 lines
16 KiB
Bash
Executable File
565 lines
16 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
################################################################################
|
|
# #
|
|
# netoik-cicd-runner #
|
|
# #
|
|
# This binary is made to be run by an unprivileged user, it clones a git repo, #
|
|
# checks code validity, builds eventually a rpm package and asks deployer #
|
|
# to deploy it. #
|
|
# #
|
|
# This script is coded with respect of google shell styleguide: #
|
|
# https://google.github.io/styleguide/shellguide.html #
|
|
# #
|
|
################################################################################
|
|
|
|
# Exit immediately if any command fails.
|
|
set -e
|
|
|
|
# Exit with the last non-zero fail code.
|
|
set -o pipefail
|
|
|
|
# Program constants.
|
|
readonly PACKAGE_NAME="netoik-cicd"
|
|
readonly PROGRAM_NAME="${PACKAGE_NAME}-runner"
|
|
|
|
# Declare default variables.
|
|
readonly DEFAULT_CONFIG_FILE="/etc/${PACKAGE_NAME}/${PACKAGE_NAME}.conf"
|
|
readonly DEFAULT_ERRORS_FILE="/etc/${PACKAGE_NAME}/errors.conf"
|
|
|
|
# Declare levels variables.
|
|
readonly VERBOSITY_LEVELS=(
|
|
[10]="CRITICAL"
|
|
[20]="ERROR"
|
|
[30]="WARN"
|
|
[40]="INFO"
|
|
[50]="DEBUG"
|
|
)
|
|
readonly DEFAULT_VERBOSITY_LEVEL=40
|
|
|
|
# Functions.
|
|
log() (
|
|
level="$1"
|
|
msg="$2"
|
|
|
|
if [ "${level}" -le "${verbosity_level}" ]; then
|
|
echo "${VERBOSITY_LEVELS[${level}]} - ${msg}"
|
|
fi
|
|
)
|
|
log_critical() (log 10 "$1" >&2)
|
|
log_error() (log 20 "$1" >&2)
|
|
log_warn() (log 30 "$1" >&2)
|
|
log_info() (log 40 "$1")
|
|
log_debug() (log 50 "$1")
|
|
|
|
fail() (
|
|
msg="$1"
|
|
if [ $# -eq 2 ]; then
|
|
code="$2"
|
|
else
|
|
code=1
|
|
fi
|
|
|
|
log_critical "${msg}"
|
|
if [ "${code}" -gt 0 ]; then
|
|
exit "${code}"
|
|
else
|
|
log_error "Undefined error code for '${msg}'."
|
|
exit 1
|
|
fi
|
|
)
|
|
|
|
usage() (
|
|
echo "Usage: ${PROGRAM_NAME} [OPTION]... ACTION
|
|
Start runner server, wait for unixsock requests to clone git repo, run tests
|
|
and build rpm package.
|
|
|
|
Mandatory argumentes for long options are mandatory for short options too.
|
|
|
|
-c, --conf=<config_file> name of configuration file, default
|
|
to ${DEFAULT_CONFIG_FILE}
|
|
-d, --daemon run processing loop in background
|
|
-e, --errs=<errors_file> name of errors file, default to
|
|
${DEFAULT_ERRORS_FILE}
|
|
-o, --once stop listening after first request process
|
|
-q, --quiet set level verbosity to WARN, default to INFO
|
|
-v, --verbose set level verbosity to DEBUG, default to INFO
|
|
|
|
-h, --help display this help message and exit
|
|
|
|
Positional argument ACTION
|
|
start start runner server
|
|
stop stop runner server
|
|
test tests configuration and exit
|
|
"
|
|
)
|
|
|
|
reply() (
|
|
code="$1"
|
|
msg="$2"
|
|
|
|
if [ "${code}" -gt 0 ]; then
|
|
log_error "${msg}"
|
|
else
|
|
log_info "${msg}"
|
|
fi
|
|
|
|
if ! output="$(jq --null-input \
|
|
--compact-output \
|
|
--arg c "${code}" \
|
|
--arg m "${msg}" \
|
|
'{"code":$c|tonumber,"msg":$m}' |
|
|
ncat --unixsock "${response_sock}" 2>&1)"; then
|
|
log_error "Cannot write to sock '${response_sock}': ${output}."
|
|
fi
|
|
)
|
|
|
|
fwd_reply() (
|
|
read -r json
|
|
|
|
if ! output="$(echo "${json}" | ncat --unixsock "${response_sock}" 2>&1)"; then
|
|
log_error "Cannot write to sock '${response_sock}': ${output}."
|
|
fi
|
|
)
|
|
|
|
process_request() (
|
|
# Get repo_name from json.
|
|
log_debug "Get response sock file."
|
|
if ! response_sock="$(echo "${request}" |
|
|
jq --raw-output ".response_sock" 2>&1)"; then
|
|
log_error "Cannot parse json request '${request}': ${response_sock}."
|
|
return
|
|
fi
|
|
if [ "${response_sock}" = "null" ]; then
|
|
log_error "Missing key 'response_sock' in request '${request}'."
|
|
return
|
|
fi
|
|
if [ -z "${response_sock}" ]; then
|
|
log_error "Empty value for key 'response_sock' in request '${request}'."
|
|
return
|
|
fi
|
|
if [ ! -S "${response_sock}" ]; then
|
|
log_error \
|
|
"Sock file '${response_sock}' does not exist in request '${request}'."
|
|
return
|
|
fi
|
|
log_debug "Sock file '${response_sock}' is reachable."
|
|
|
|
# Get repo_name from json.
|
|
log_debug "Get repo_name from json."
|
|
if ! repo_name="$(echo "${request}" |
|
|
jq --raw-output ".repo_name" 2>&1)"; then
|
|
reply "${err_json_bad_format}" \
|
|
"Cannot parse json '${request}': ${repo_name}."
|
|
return
|
|
fi
|
|
if [ "${repo_name}" = "null" ]; then
|
|
reply "${err_repo_name_missing}" "Missing key repo_name in '${request}'."
|
|
return
|
|
fi
|
|
if [ -z "${repo_name}" ]; then
|
|
reply "${err_repo_name_empty}" \
|
|
"Empty value for key repo_name in '${request}'."
|
|
return
|
|
fi
|
|
log_debug "Got pkg name: '${repo_name}'."
|
|
|
|
# Get repository folder.
|
|
repo_dir="${repos_dir}/${repo_name}.git"
|
|
if [ ! -d "${repo_dir}" ]; then
|
|
reply "${err_repo_dir_not_exist}" "Repository '${repo_dir}' does not exist."
|
|
return
|
|
fi
|
|
log_debug "Repository is present at '${repo_dir}'."
|
|
|
|
# Get repo_hash from json.
|
|
log_debug "Get repo_hash from json."
|
|
if ! repo_hash=$(echo "${request}" | jq --raw-output ".repo_hash" 2>&1); then
|
|
reply "${err_json_bad_format}" \
|
|
"Cannot parse json '${request}': ${repo_hash}."
|
|
return
|
|
fi
|
|
if [ "${repo_hash}" = "null" ]; then
|
|
reply "${err_repo_hash_missing}" "Missing key repo_hash in '${request}'."
|
|
return
|
|
fi
|
|
if [ -z "${repo_hash}" ]; then
|
|
reply "${err_repo_hash_empty}" \
|
|
"Empty value for key repo_hash in '${request}'." 1>&2
|
|
return
|
|
fi
|
|
|
|
# Get repo_tag from json.
|
|
log_debug "Get repo_tag from json."
|
|
if ! repo_tag=$(echo "${request}" | jq --raw-output ".repo_tag"); then
|
|
reply "${err_json_bad_format}" \
|
|
"Cannot parse json '${request}': ${repo_tag}."
|
|
return
|
|
fi
|
|
if [ -z "${repo_tag}" ]; then
|
|
reply "${err_repo_tag_empty}" \
|
|
"Empty value for key repo_tag in '${request}'."
|
|
return
|
|
fi
|
|
|
|
# Clone repo and move into it.
|
|
repo_id="$(echo "${repo_hash}" | head --bytes 7)"
|
|
[ "${repo_tag}" != "null" ] && repo_id="${repo_tag}"
|
|
rand="$(echo "${RANDOM}" | md5sum | head --bytes 7)"
|
|
repo_clone="${runner_cloning_dir}/${repo_name}-${repo_id}-${rand}"
|
|
log_info "Clone '$repo_name'."
|
|
if ! output="$(git clone "$repo_dir" "$repo_clone" 2>&1)"; then
|
|
reply "${err_clone_repo}" "Cannot clone repo '${repo_name}': ${output}."
|
|
return
|
|
fi
|
|
cd "$repo_clone"
|
|
|
|
# Checkout git hash.
|
|
log_info "Checkout hash $repo_hash."
|
|
if ! output="$(git checkout "$repo_hash" 2>&1)"; then
|
|
reply "${err_checkout_hash}" \
|
|
"Cannot checkout hash '${repo_hash}': ${output}."
|
|
return
|
|
fi
|
|
|
|
# Check code validity.
|
|
log_info "Check code validity."
|
|
if ! output="$(make check_format 2>&1)"; then
|
|
reply "${err_check_format}" "Check format error: ${output}."
|
|
return
|
|
fi
|
|
if ! output="$(make check_linting 2>&1)"; then
|
|
reply "${err_check_linting}" "Check linting error: ${output}."
|
|
return
|
|
fi
|
|
if ! output="$(make unit_test 2>&1)"; then
|
|
reply "${err_unit_test}" "Unit test error: ${output}."
|
|
return
|
|
fi
|
|
|
|
# Stop now if no tag specified.
|
|
if [ "$repo_tag" = "null" ]; then
|
|
reply 0 "Hash '${repo_hash}' OK for repo '${repo_name}'."
|
|
return
|
|
fi
|
|
|
|
# Build rpm package.
|
|
echo "Build RPM package."
|
|
if ! output="$(git tag --message="$repo_tag" "$repo_tag" 2>&1)"; then
|
|
reply "${err_add_tag}" "Cannot add git tag '${repo_tag}': ${output}."
|
|
return
|
|
fi
|
|
if ! output="$(make tarball 2>&1)"; then
|
|
reply "${err_make_tarball}" \
|
|
"Cannot make tarball for tag '${repo_tag}': ${output}."
|
|
return
|
|
fi
|
|
if ! output="$(rpmbuild -bb "$repo_name.spec" 2>&1)"; then
|
|
reply "${err_rpm_build}" \
|
|
"Cannot build rpm for tag '${repo_tag}': ${output}."
|
|
return
|
|
fi
|
|
|
|
# Deploy rpm package.
|
|
log_info "Deploy RPM package."
|
|
deployer_request="$(jq --null-input \
|
|
--compact-output \
|
|
--arg s "${repo_clone}/runner.sock" \
|
|
--arg n "${repo_name}" \
|
|
--arg v "$(make version)" \
|
|
'{"response_sock":$s,"pkg_name":$n,"pkg_version":$v}')"
|
|
(
|
|
inotifywait --timeout 1 --quiet --quiet \
|
|
--event create "${repo_clone}" || true
|
|
chmod 775 "${repo_clone}/runner.sock"
|
|
chgrp "${runner_deployer_groupname}" "${repo_clone}/runner.sock"
|
|
echo "${deployer_request}" | ncat --unixsock "${deployer_sock}"
|
|
) &
|
|
|
|
# Wait for deployer response.
|
|
if [ "${deployer_timeout}" -gt 0 ]; then
|
|
timeout "${deployer_timeout}" ncat --listen --unixsock runner.sock |
|
|
fwd_reply
|
|
else
|
|
ncat --listen --unixsock runner.sock | fwd_reply
|
|
fi
|
|
)
|
|
|
|
process_loop() (
|
|
# Loop on every request.
|
|
if "${keep_open}"; then
|
|
log_info "Start listening unixsock with keep-open at '${runner_sock}'."
|
|
while read -r request; do
|
|
log_info "Process new request '${request}'."
|
|
process_request &
|
|
done < <(ncat --listen --keep-open --unixsock "${runner_sock}")
|
|
log_critical "Unexpected end of listening at '${deployer_sock}'."
|
|
else
|
|
log_info "Start listening unixsock without keep-open at '${runner_sock}'."
|
|
while read -r request; do
|
|
log_info "Process new request '${request}'."
|
|
process_request
|
|
done < <(ncat --listen --unixsock "${runner_sock}")
|
|
log_info "End of listening at '${deployer_sock}'."
|
|
fi
|
|
)
|
|
|
|
main() (
|
|
# Parse arguments.
|
|
daemonize="false"
|
|
keep_open="true"
|
|
config_file="${DEFAULT_CONFIG_FILE}"
|
|
errors_file="${DEFAULT_ERRORS_FILE}"
|
|
verbosity_option=""
|
|
verbosity_level="${DEFAULT_VERBOSITY_LEVEL}"
|
|
if ! args="$(getopt --name "${PROGRAM_NAME}" \
|
|
--options c:de:hoqv \
|
|
--longoptions conf:,daemon,errs:,help,once,quiet,verbose \
|
|
-- "$@")"; then
|
|
usage
|
|
fail "Bad arguments."
|
|
fi
|
|
eval set -- "${args}"
|
|
|
|
while true; do
|
|
case "$1" in
|
|
-c | --conf)
|
|
config_file="$2"
|
|
shift 2
|
|
;;
|
|
-d | --daemon)
|
|
daemonize="true"
|
|
shift
|
|
;;
|
|
-e | --errs)
|
|
errors_file="$2"
|
|
shift 2
|
|
;;
|
|
-h | --help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
-o | --once)
|
|
keep_open="false"
|
|
shift
|
|
;;
|
|
-q | --quiet)
|
|
if [ "${verbosity_option}" ]; then
|
|
usage
|
|
fail "Options $1 and ${verbosity_option} are mutually exclusive."
|
|
fi
|
|
verbosity_option="$1"
|
|
((verbosity_level -= 10))
|
|
shift
|
|
;;
|
|
-v | --verbose)
|
|
if [ "${verbosity_option}" ]; then
|
|
usage
|
|
fail "Options $1 and ${verbosity_option} are mutually exclusive."
|
|
fi
|
|
verbosity_option="$1"
|
|
((verbosity_level += 10))
|
|
shift
|
|
;;
|
|
--)
|
|
shift
|
|
break
|
|
;;
|
|
*)
|
|
usage
|
|
fail "Unexpected option '$1'."
|
|
;;
|
|
esac
|
|
done
|
|
if [ $# -ne 1 ]; then
|
|
usage
|
|
fail "Missing positional argument ACTION."
|
|
fi
|
|
case "$1" in
|
|
start | stop | test)
|
|
action="$1"
|
|
;;
|
|
*)
|
|
usage
|
|
fail "Bad postional argument ACTION '$1'."
|
|
;;
|
|
esac
|
|
|
|
# Load config file.
|
|
if [ ! -r "${config_file}" ]; then
|
|
fail "Config file '${config_file}' is not readable."
|
|
fi
|
|
log_info "Load config file '${config_file}'."
|
|
# shellcheck source=./conf/netoik-cicd.conf.sample
|
|
source "${config_file}"
|
|
|
|
# Load errors file.
|
|
if [ ! -r "${errors_file}" ]; then
|
|
fail "Errors file '${errors_file}' is not reachable."
|
|
fi
|
|
log_info "Load errors file '${errors_file}'."
|
|
# shellcheck source=./conf/errors.conf.sample
|
|
source "${errors_file}"
|
|
|
|
# Check variables in config file.
|
|
if [ -z "${deployer_sock}" ]; then
|
|
fail "Variable deployer_sock is empty." "${err_deployer_sock_empty}"
|
|
fi
|
|
if [ ! -d "$(dirname "${deployer_sock}")" ]; then
|
|
fail "Dirname of deployer_sock='${deployer_sock}' is not a directory." \
|
|
"${err_deployer_sock_dir_not_directory}"
|
|
fi
|
|
if [ ! -x "$(dirname "${deployer_sock}")" ]; then
|
|
fail "Dirname of deployer_sock='${deployer_sock}' is not accessible." \
|
|
"${err_deployer_sock_dir_not_accessible}"
|
|
fi
|
|
|
|
if [ -z "${deployer_timeout}" ]; then
|
|
fail "Variable deployer_timeout is empty." "${err_deployer_timeout_empty}"
|
|
fi
|
|
if [ "${deployer_timeout}" -lt 0 ]; then
|
|
fail "Deployer timeout is not valid: ${deployer_timeout}." \
|
|
"${err_deployer_timeout_not_valid}"
|
|
fi
|
|
|
|
if [ -z "${runner_sock}" ]; then
|
|
fail "Variable runner_sock is empty." "${err_runner_sock_empty}"
|
|
fi
|
|
if [ ! -d "$(dirname "${runner_sock}")" ]; then
|
|
fail "Dirname of runner_sock='${runner_sock}' is not a directory." \
|
|
"${err_runner_sock_dir_not_directory}"
|
|
fi
|
|
if [ ! -w "$(dirname "${runner_sock}")" ]; then
|
|
fail "Directory of runner_sock='${runner_sock}' is not writable." \
|
|
"${err_runner_sock_dir_not_writable}"
|
|
fi
|
|
|
|
if [ -z "${runner_cloning_dir}" ]; then
|
|
fail "Variable runner_cloning_dir is empty." \
|
|
"${err_runner_cloning_dir_empty}"
|
|
fi
|
|
if [ ! -d "${runner_cloning_dir}" ]; then
|
|
fail "File runner_cloning_dir='${runner_cloning_dir}' is not a directory." \
|
|
"${err_runner_cloning_dir_not_directory}"
|
|
fi
|
|
if [ ! -w "${runner_cloning_dir}" ]; then
|
|
fail "Dir runner_cloning_dir='${runner_cloning_dir}' is not writable." \
|
|
"${err_runner_cloning_dir_not_writable}"
|
|
fi
|
|
|
|
if [ -z "${repos_dir}" ]; then
|
|
fail "Variable repos_dir is empty." "${err_repos_dir_empty}"
|
|
fi
|
|
if [ ! -d "${repos_dir}" ]; then
|
|
fail "File repos_dir='${repos_dir}' is not a directory." \
|
|
"${err_repos_dir_not_directory}"
|
|
fi
|
|
if [ ! -x "${repos_dir}" ]; then
|
|
fail "Directory repos_dir='${repos_dir}' is not accessible." \
|
|
"${err_repos_dir_not_accessible}"
|
|
fi
|
|
|
|
if [ -z "${deployer_username}" ]; then
|
|
fail "Variable deployer_username is empty." "${err_deployer_username_empty}"
|
|
fi
|
|
if ! id --user --name "${deployer_username}" 1>/dev/null; then
|
|
fail "Deployer user '${deployer_username}' does not exist." \
|
|
"${err_deployer_user_not_exist}"
|
|
fi
|
|
|
|
if [ -z "${runner_deployer_groupname}" ]; then
|
|
fail "Variable runner_deployer_groupname is empty." "${err_runner_deployer_groupname_empty}"
|
|
fi
|
|
if ! getent group "${runner_deployer_groupname}" >/dev/null; then
|
|
fail "Runner-deployer group '${runner_deployer_groupname}' does not exist." \
|
|
"${err_runner_deployer_group_not_exist}"
|
|
fi
|
|
|
|
if [ -z "${git_runner_groupname}" ]; then
|
|
fail "Variable git_runner_groupname is empty." "${err_git_runner_groupname_empty}"
|
|
fi
|
|
if ! getent group "${git_runner_groupname}" >/dev/null; then
|
|
fail "Git-runner group '${git_runner_groupname}' does not exist." \
|
|
"${err_git_runner_group_not_exist}"
|
|
fi
|
|
|
|
if [ -z "${runner_pid}" ]; then
|
|
fail "Variable runner_pid is empty." "${err_runner_pid_empty}"
|
|
fi
|
|
if [ ! -d "$(dirname "${runner_pid}")" ]; then
|
|
fail "Dirname of runner_pid='${runner_pid}' is not a directory." \
|
|
"${err_runner_pid_dir_not_directory}"
|
|
fi
|
|
if [ ! -w "$(dirname "${runner_pid}")" ]; then
|
|
fail "Dirname of runner_pid='${runner_pid}' is not writable." \
|
|
"${err_runner_pid_dir_not_writable}"
|
|
fi
|
|
|
|
# Run chosen action.
|
|
case "${action}" in
|
|
test)
|
|
# Stop now if we are only testing.
|
|
log_info "Configuration OK."
|
|
exit 0
|
|
;;
|
|
start)
|
|
if [ ! -S "${deployer_sock}" ]; then
|
|
fail "Sock deployer_sock='${deployer_sock}' does not exist." \
|
|
"${err_deployer_sock_not_exist}"
|
|
fi
|
|
if [ -e "${runner_sock}" ]; then
|
|
fail "Sock deployer_sock='${runner_sock}' is already in use." \
|
|
"${err_runner_sock_already_in_use}"
|
|
fi
|
|
|
|
# Set right access in background after nc listen.
|
|
(
|
|
inotifywait --timeout 1 --quiet --quiet \
|
|
--event create "$(dirname "${runner_sock}")" || true
|
|
chmod 775 "${runner_sock}"
|
|
chgrp "${git_runner_groupname}" "${runner_sock}"
|
|
) &
|
|
|
|
# Run process loop in background or foreground.
|
|
if "${daemonize}"; then
|
|
log_info "Run process loop in background."
|
|
process_loop &
|
|
echo "$!" >"${runner_pid}"
|
|
else
|
|
log_info "Run process loop in foreground."
|
|
echo "$$" >"${runner_pid}"
|
|
process_loop
|
|
fi
|
|
;;
|
|
stop)
|
|
if [ ! -f "${runner_pid}" ]; then
|
|
fail "File runner_pid='${runner_pid}' does not exist." \
|
|
"${err_runner_pid_not_exist}"
|
|
fi
|
|
if [ ! -r "${runner_pid}" ]; then
|
|
fail "File runner_pid='${runner_pid}' is not readable." \
|
|
"${err_runner_pid_not_readable}"
|
|
fi
|
|
pid="$(cat "${runner_pid}")"
|
|
rm --force "${runner_pid}"
|
|
|
|
# Kill runner process.
|
|
log_info "Kill runner process with pid '${pid}'."
|
|
if ! ps -p "${pid}"; then
|
|
fail "Runner process with pid='${pid}' is not running." \
|
|
"${err_runner_process_not_running}"
|
|
fi
|
|
if ! output="$(kill "${pid}" || kill -KILL "${pid}")"; then
|
|
fail "Cannot kill runner process: ${output}." \
|
|
"${err_runner_process_not_killed}"
|
|
fi
|
|
|
|
# Remove runner sock file.
|
|
log_info "Remove runner sock at '${runner_sock}'."
|
|
rm --force "${runner_sock}"
|
|
;;
|
|
esac
|
|
)
|
|
|
|
main "$@"
|