#!/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= name of configuration file, default to ${DEFAULT_CONFIG_FILE} -d, --daemon run processing loop in background -e, --errs= 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() ( msg="$1" code="$2" if [ -z "${code}" ]; then log_info "${msg}" json="$(jq --null-input \ --compact-output \ --arg m "${msg}" \ '{"msg":$m}')" else if [ "${code}" -eq 0 ]; then log_info "${msg}" else log_error "${msg}" fi json="$(jq --null-input \ --compact-output \ --arg m "${msg}" \ --arg c "${code}" \ '{"msg":$m,"code":$c|tonumber}')" fi if ! output="$(echo "${json}" | 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 "Cannot parse json '${request}': ${repo_name}." \ "${err_json_bad_format}" return fi if [ "${repo_name}" = "null" ]; then reply "Missing key repo_name in '${request}'." \ "${err_repo_name_missing}" return fi if [ -z "${repo_name}" ]; then reply "Empty value for key repo_name in '${request}'." \ "${err_repo_name_empty}" 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 "Repository '${repo_dir}' does not exist." \ "${err_repo_dir_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 "Cannot parse json '${request}': ${repo_hash}." \ "${err_json_bad_format}" return fi if [ "${repo_hash}" = "null" ]; then reply "Missing key repo_hash in '${request}'." \ "${err_repo_hash_missing}" return fi if [ -z "${repo_hash}" ]; then reply "Empty value for key repo_hash in '${request}'." \ "${err_repo_hash_empty}" 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 "Cannot parse json '${request}': ${repo_tag}." \ "${err_json_bad_format}" return fi if [ -z "${repo_tag}" ]; then reply "Empty value for key repo_tag in '${request}'." \ "${err_repo_tag_empty}" 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}" reply "Cloning repo '${repo_name}'..." if ! output="$(git clone "${repo_dir}" "${repo_clone}" 2>&1)"; then reply "Cannot clone repo '${repo_name}': ${output}." \ "${err_clone_repo}" return fi cd "$repo_clone" # Checkout git hash. reply "Checkouting hash '${repo_hash}..." if ! output="$(git checkout "$repo_hash" 2>&1)"; then reply "Cannot checkout hash '${repo_hash}': ${output}." \ "${err_checkout_hash}" return fi # Check code validity. reply "Checking code format..." if ! output="$(make check_format 2>&1)"; then reply "Check format error: ${output}." "${err_check_format}" return fi reply "Checking code linting..." if ! output="$(make check_linting 2>&1)"; then reply "Check linting error: ${output}." "${err_check_linting}" return fi reply "Running unit tests..." if ! output="$(make unit_test 2>&1)"; then reply "Unit test error: ${output}." "${err_unit_test}" return fi # Stop now if no tag specified. if [ "$repo_tag" = "null" ]; then reply "Hash '${repo_hash}' OK for repo '${repo_name}'." 0 return fi # Build rpm package. reply "Adding tag '${repo_tag}'..." if ! output="$(git tag --message="$repo_tag" "$repo_tag" 2>&1)"; then reply "Cannot add git tag '${repo_tag}': ${output}." "${err_add_tag}" return fi reply "Making source tarball..." if ! output="$(make tarball 2>&1)"; then reply "Cannot make tarball for tag '${repo_tag}': ${output}." \ "${err_make_tarball}" return fi repo_version="$(make version)" reply "Building RPM package '${repo_name}' v${repo_version}..." if ! output="$(rpmbuild -bb "$repo_name.spec" 2>&1)"; then reply "Cannot build rpm for tag '${repo_tag}': ${output}." \ "${err_rpm_build}" return fi # Deploy rpm package. reply "Deploying RPM package '${repo_name}' v${repo_version}..." deployer_request="$(jq --null-input \ --compact-output \ --arg s "${repo_clone}/runner.sock" \ --arg n "${repo_name}" \ --arg v "${repo_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}" >/dev/null; 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 "$@"