#!/usr/bin/env bash
# Copyright 2001-2025, Paul Johnson (paul@pjcj.net)
# This software is free. It is licensed under the same terms as Perl itself.
# The latest version of this software should be available from my homepage:
# https://pjcj.net
if ((BASH_VERSINFO[0] < 5)); then
echo "❌ bash version $BASH_VERSION is too old. Please install v5 or higher."
exit 1
fi
set -eEuo pipefail
shopt -s inherit_errexit
_p() {
__l="$(hostname): $1"
shift
echo "$__l $script: $*" | tee -a "$LOG_FILE" >&2
}
pt() { _p "[TRACE] " "$*"; }
pd() { _p "[DEBUG] " "$*"; }
pi() { _p "[INFO] " "$*"; }
pw() { _p "[WARNING]" "$*"; }
pe() { _p "[ERROR] " "$*"; }
pf() {
_p "[FATAL] " "$*"
exit 1
}
usage() {
cat <<EOT
$script --help
$script --trace --verbose
$script --env=dev cpancover-controller-run-once
$script --results_dir=/cover/dev --image=pjcj/cpancover_dev cpancover-run
EOT
exit 0
}
cleanup() {
declare -r res=$?
# ((verbose)) && pi "Cleaning up"
exit "$res"
}
parse_options() {
while [[ $# -gt 0 ]]; do
case "$1" in
-d | --dryrun)
dryrun=1
shift
;;
-e | --env)
shift
env="$1"
case "$env" in
prod)
results_dir=~/cover/staging
docker_image=pjcj/cpancover
;;
dev)
results_dir=~/cover/staging_dev
docker_image=pjcj/cpancover_dev
;;
*)
pf "Unrecognised environment: $1"
;;
esac
shift
if grep -q "/docker/" /proc/self/mountinfo 2>/dev/null; then
# we're inside a docker container so use the remote staging directory
results_dir=/remote_staging
fi
;;
-f | --force)
force=1
shift
;;
-h | --help)
usage
;;
-i | --image)
docker_image="$2"
shift 2
;;
-r | --results_dir)
results_dir="$2"
shift 2
;;
-t | --trace)
set -x
shift
;;
-v | --verbose)
verbose=1
shift
;;
*)
recipe="$1"
shift
args=("$@")
break
;;
esac
done
}
recipe_options() {
echo "-d --dryrun"
echo "-e --env"
echo "-f --force"
echo "-h --help"
echo "-i --image"
echo "-r --results_dir"
echo "-t --trace"
echo "-v --verbose"
declare -F | perl -nE 'say $1 if /recipe_(.+)/'
}
setup() {
script=$(basename "$0")
readl=readlink
if command -v greadlink >&/dev/null; then readl=greadlink; fi
srcdir=$("$readl" -f "$(dirname "$0")")
readonly LOG_FILE="/tmp/$script.log"
export AUTOMATED_TESTING=1
export NONINTERACTIVE_TESTING=1
export EXTENDED_TESTING=1
PATH="$srcdir:$PATH"
docker=docker
docker_image=pjcj/cpancover
dryrun=0
env=prod
force=0
results_dir=~/cover/staging
verbose=0
parse_options "$@"
}
nice_cpus() {
perl -Iutils -MDevel::Cover::BuildUtils=nice_cpus -e "print nice_cpus"
}
recipe_nice-cpus() {
nice_cpus
}
recipe_update-copyright() {
local from="${1:-$(date +'%Y' --date='last year')}"
local to="${2:-$(date +'%Y')}"
pi "Updating copyright from $from to $to"
local me="Paul Johnson"
local files
files=$(git ls-files)
# shellcheck disable=SC2086
perl -pi -e "s/Copyright \\d+-\\K$from(, $me)/$to\$1/i" $files
# shellcheck disable=SC2086
perl -pi -e "s/Copyright $from\\K(, $me)/-$to\$1/i" $files
}
get_cpm() {
cpanm --notest App::cpm
plenv rehash
cpm=$(plenv which cpm)
}
install_dependencies() {
get_cpm
pi "Installing dependencies with $cpm"
$cpm install --workers="$(nice_cpus)" --global \
Sereal Digest::MD5 Template Pod::Coverage::CountParents \
Capture::Tiny Parallel::Iterator Template Class::XSAccessor
}
install_development_dependencies() {
get_cpm
$cpm install --workers="$(nice_cpus)" --global \
Dist::Zilla Perl::Critic Perl::Tidy App::perlimports \
Perl::Critic::PJCJ
plenv rehash
dzil authordeps --missing |
xargs "$cpm" install --workers="$(nice_cpus)" --global
dzil listdeps --missing |
xargs "$cpm" install --workers="$(nice_cpus)" --global
}
install_test_dependencies() {
get_cpm
$cpm install --workers="$(nice_cpus)" --global \
DBM::Deep
}
recipe_install-dependencies() {
install_dependencies
}
recipe_install-development-dependencies() {
install_development_dependencies
}
recipe_install-test-dependencies() {
install_test_dependencies
}
install_perl() {
local name="${1:?No name specified}"
local version="${2:?No version specified}"
yes | plenv uninstall "$name" || true
plenv install --as "$name" -j 32 -D usedevel --noman "$version"
export PLENV_VERSION="$name"
plenv install-cpanm
install_dependencies
}
recipe_install-perl() {
local name="${1:?No name specified}"
local version="${2:?No version specified}"
install_perl "$name" "$version"
}
recipe_install-cpancover-perl() {
local version="${1:?No version specified}"
install_perl cpancover "$version"
}
recipe_install-dc-dev-perl() {
local version="${1:?No version specified}"
install_perl dc-dev "$version"
install_development_dependencies
}
recipe_all-versions() {
./utils/all_versions "$@"
}
run_cpancover() {
mkdir -p "$results_dir"
local cpancover=cpancover
if [[ $(pwd) != /dc ]]; then
local root=
[[ -d /dc ]] && root=/dc/
PATH="./utils:./bin:$PATH"
perl Makefile.PL && make
cpancover="perl -Mblib=$root ${root}bin/cpancover --local"
fi
((verbose)) && cpancover="$cpancover --verbose"
((force)) && cpancover="$cpancover --force"
((dryrun)) && cpancover="$cpancover --dryrun"
local cmd
cmd="$cpancover --env $env --results_dir $results_dir"
cmd="$cmd --workers $(nice_cpus) $*"
((verbose)) && pi "$cmd"
$cmd || true
}
recipe_cpancover() {
run_cpancover "${args[@]:-}"
}
cpancover_docker_ps() {
local name="${docker_image//[^a-zA-Z0-9_.]/-}"
$docker ps -a | tail -n +2 | grep "$name-" | grep -vw "$(hostname)"
}
recipe_cpancover-docker-ps() {
cpancover_docker_ps
}
cpancover_docker_ps_ids() {
cpancover_docker_ps | awk '{ print $1 }' || true
}
recipe_cpancover-docker-kill() {
cpancover_docker_ps_ids | xargs -r "$docker" kill
}
cpancover_docker_rm() {
cpancover_docker_ps_ids | xargs -r "$docker" rm -f
$docker system prune --force
}
recipe_cpancover-docker-rm() {
cpancover_docker_rm
}
recipe_cpancover-docker-rm-image() {
$docker ps -q --filter ancestor="$docker_image" | xargs -r "$docker" stop
$docker ps -aq --filter ancestor="$docker_image" | xargs -r "$docker" rm
$docker rmi "$docker_image"
}
cpancover_latest() {
run_cpancover --latest
}
recipe_cpancover-latest() {
cpancover_latest
}
recipe_cpancover-build-module() {
local module="${1:?No module specified}"
local v=
((verbose)) && v=--verbose
run_cpancover "$v" --local_build --docker "$docker" --workers 1 "$module"
}
docker_name() {
local name="${1:?No name specified}"
name="$docker_image-$name-$(date +%s+%N)"
echo "${name//[^a-zA-Z0-9_.]/-}"
}
recipe_docker-name() {
docker_name "${args[@]:-}"
}
cpancover_controller_command() {
local name="${1:?No name specified}"
shift
local cmd=("$@")
mkdir -p "$results_dir"
local sock=/var/run/docker.sock
$docker run -it \
--mount type=bind,source=$sock,target=$sock \
--mount type=bind,source="$results_dir",target=/remote_staging \
--rm=false --memory=1g --name="$(docker_name "$name")" \
"$docker_image" "${cmd[@]}"
}
recipe_cpancover-controller-shell() {
cpancover_controller_command controller_bash "/bin/bash"
}
recipe_cpancover-docker-shell() {
local staging="${1:-$results_dir}"
mkdir -p "$staging"
$docker run -it \
--mount type=bind,source="$staging",target=/remote_staging \
--rm=false --memory=1g --name="$(docker_name docker)" \
"$docker_image" /bin/bash
}
# Called from Collection.pm
recipe_cpancover-docker-module() {
local module="${1:?No module specified}"
local name="${2:?No name specified}"
local staging="${3:-$results_dir}"
name=$(docker_name "$name")
mkdir -p "$staging"
((verbose)) && pi "module: $module"
local v=
((verbose)) && v=--verbose
container=$($docker run -d \
--rm=false --memory=1g --name="$name" \
"$docker_image" \
dc $v cpancover-build-module "$module")
((verbose)) && pi "container is $container"
$docker wait "$name" # >/dev/null
# shellcheck disable=SC2181
if [[ $? == 0 ]]; then
$docker logs "$name"
local_staging="$staging/$name"
mkdir -p "$local_staging"
$docker cp "$name:/root/cover/staging" "$local_staging"
if [[ -d $local_staging ]]; then
chmod -R 755 "$local_staging"
find "$local_staging" -type f -exec chmod 644 {} \;
chown -R "$(id -un):$(id -gn)" "$local_staging"
cd "$local_staging"/* || exit
for f in *; do
if [[ -d $f ]]; then
rm -rf "${staging:?}/$f"
mv "$f" "$staging"
fi
done
rm -r "$local_staging"
fi
fi
$docker rm "$name" >/dev/null
}
cpancover_compress() {
rm -rf "$results_dir"/*/{runs,structure}
find "$results_dir/" -name __failed__ -prune -o \
-type f -not -name '*.gz' -not -name '*.json' \
-exec gzip -f9 {} \;
}
recipe_cpancover-compress() {
cpancover_compress
}
recipe_cpancover-uncompress-dir() {
subdir="${1:?No subdir specified}"
find "$results_dir/$subdir/" -name __failed__ -prune -o \
-type f -name '*.gz' \
-exec gzip -d {} \;
}
cpancover_compress_old_versions() {
keep="${1:-3}"
run_cpancover --nobuild --compress_old_versions "$keep"
}
recipe_cpancover-compress-old-versions() {
cpancover_compress_old_versions "${args[@]:-}"
}
cpancover_generate_html() {
pi "Generating HTML at $(date)"
cpancover_compress_old_versions
run_cpancover --generate_html
cpancover_compress
local json=$results_dir/cpancover.json
local tmp=$json-tmp-$$.gz
pi "Compressing $json"
pigz <"$json" >"$tmp" && mv "$tmp" "$json.gz"
pi "Done"
}
recipe_cpancover-generate-html() {
cpancover_generate_html
}
cpancover_run_once() {
pi "Starting cpancover run at $(date) on $(nice_cpus) cpus"
cpancover_docker_rm
cpancover_latest | run_cpancover --build
cpancover_generate_html
pi "Finished cpancover run at $(date)"
}
cpancover_run_loop() {
while true; do
cpancover_run_once
sleep 600 # 10 minutes
done
}
recipe_cpancover-run-once() {
cpancover_run_once
}
recipe_cpancover-run-loop() {
cpancover_run_loop
}
recipe_cpancover-controller-run() {
local o=(--env "$env")
((verbose)) && o+=("--verbose")
cpancover_controller_command controller dc "${o[@]}" cpancover-run-loop
}
recipe_cpancover-controller-run-once() {
local o=(--env "$env")
((verbose)) && o+=("--verbose")
cpancover_controller_command controller dc "${o[@]}" cpancover-run-once
}
recipe_cpancover-start-queue() {
COVER_DEBUG=1 perl bin/queue minion worker -j 4
}
recipe_cpancover-start-minion() {
COVER_DEBUG=1 perl bin/queue daemon -l http://\*:30000 -m production
}
recipe_cpancover-add() {
module="${1:?No module specified}"
COVER_DEBUG=1 perl bin/queue add "$module"
}
run_recipe() {
recipe="recipe_$recipe"
shift
if declare -F "$recipe" >/dev/null 2>&1; then
"$recipe" "${args[@]:-}"
else
pf "Unknown recipe: $recipe"
fi
}
main() {
setup "$@"
((verbose)) && pi "Running $recipe ${args[*]:-}"
[[ ${recipe:-} == "" ]] && pf "Missing recipe"
run_recipe "${args[@]:-}"
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
trap cleanup EXIT INT
main "$@"
fi