#!/bin/bash set -Eeuo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TMP_PARENT="${TMPDIR:-/tmp}" KEEP_WORKDIR=0 if [[ -n "${ENROLL_TEST_WORKDIR:-}" ]]; then WORK_DIR="${ENROLL_TEST_WORKDIR}" KEEP_WORKDIR=1 mkdir -p "${WORK_DIR}" else WORK_DIR="$(mktemp -d "${TMP_PARENT%/}/enroll-tests.XXXXXX")" fi BUNDLE_DIR="${WORK_DIR}/bundle" BUNDLE_DIFF_DIR="${WORK_DIR}/bundle-diff" ANSIBLE_DIR="${WORK_DIR}/ansible" ANSIBLE_NO_COMMON_DIR="${WORK_DIR}/ansible-no-common" ANSIBLE_FQDN_DIR="${WORK_DIR}/ansible-fqdn" ANSIBLE_JINJATURTLE_DIR="${WORK_DIR}/ansible-jinjaturtle" ANSIBLE_NO_JINJATURTLE_DIR="${WORK_DIR}/ansible-no-jinjaturtle" TEST_FQDN="${ENROLL_TEST_FQDN:-enroll-ci.example.test}" JINJATURTLE_FIXTURE="${WORK_DIR}/enroll-tests-jinjaturtle.ini" ANSIBLE_PLAYBOOK_EXTRA_ARGS=() cleanup() { if [[ "${KEEP_WORKDIR}" -eq 0 ]]; then rm -rf "${WORK_DIR}" else printf '\nKeeping ENROLL_TEST_WORKDIR: %s\n' "${WORK_DIR}" fi } trap cleanup EXIT section() { printf '\n================================================================================\n' printf '%s\n' "$1" printf '================================================================================\n' } run() { printf '+ ' printf '%q ' "$@" printf '\n' "$@" } fail() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } require_root() { if [[ "$(id -u)" -ne 0 ]]; then fail "tests.sh must be run as root so harvest and CM noop tests can inspect/apply system state." fi } require_supported_ci_os() { if [[ -r /etc/os-release ]]; then # shellcheck disable=SC1091 . /etc/os-release case "${ID:-}" in debian) if [[ "${VERSION_ID:-}" != "13" ]]; then printf 'WARNING: tests.sh is maintained for Debian 13 CI; detected Debian %s.\n' "${VERSION_ID:-unknown}" >&2 fi ;; almalinux|rhel|rocky|centos|fedora) printf 'Detected RPM-family CI host: %s %s.\n' "${ID:-unknown}" "${VERSION_ID:-unknown}" >&2 ;; *) printf 'WARNING: tests.sh is maintained for Debian 13 and AlmaLinux/RHEL-family CI; detected %s %s.\n' "${ID:-unknown}" "${VERSION_ID:-unknown}" >&2 ;; esac fi } pid1_comm() { if [[ -r /proc/1/comm ]]; then tr -d '[:space:]' /dev/null 2>&1; then ps -p 1 -o comm= 2>/dev/null | tr -d '[:space:]' || true fi } configure_ansible_playbook_extra_args() { local pid1 pid1="$(pid1_comm)" ANSIBLE_PLAYBOOK_EXTRA_ARGS=() if [[ "${pid1}" != "systemd" ]]; then section "Setup: Ansible systemd runtime guard" printf 'PID 1 is %s, not systemd; disabling generated Ansible systemd runtime enforcement for CI noop plays.\n' "${pid1:-unknown}" ANSIBLE_PLAYBOOK_EXTRA_ARGS=(-e enroll_manage_systemd_runtime=false) fi } os_id() { if [[ -r /etc/os-release ]]; then # shellcheck disable=SC1091 . /etc/os-release printf '%s' "${ID:-unknown}" else printf 'unknown' fi } os_version_major() { if [[ -r /etc/os-release ]]; then # shellcheck disable=SC1091 . /etc/os-release printf '%s' "${VERSION_ID%%.*}" else printf 'unknown' fi } is_debian() { [[ "$(os_id)" == "debian" ]] } is_rpm_family() { case "$(os_id)" in almalinux|rhel|rocky|centos|fedora) return 0 ;; *) return 1 ;; esac } pkg_update_once() { if is_debian; then if [[ -z "${APT_UPDATED:-}" ]]; then section "Setup: apt metadata" run apt-get update APT_UPDATED=1 fi elif is_rpm_family; then if [[ -z "${DNF_UPDATED:-}" ]]; then section "Setup: dnf metadata" run dnf -y makecache DNF_UPDATED=1 fi else fail "Unsupported package manager for OS $(os_id)." fi } translate_packages() { local translated=() local pkg for pkg in "$@"; do if is_debian; then translated+=("${pkg}") continue fi case "${pkg}" in ansible) translated+=(ansible-core) ;; apache2) translated+=(httpd) ;; gnupg) translated+=(gnupg2) ;; curl) translated+=(curl-minimal) ;; lsb-release) translated+=(redhat-lsb-core) ;; python3-apt) ;; python3-jsonschema) translated+=(python3-jsonschema) ;; python3-venv) ;; systemctl) translated+=(systemd) ;; *) translated+=("${pkg}") ;; esac done printf '%s\n' "${translated[@]}" } pkg_install() { local packages=() local pkg while IFS= read -r pkg; do [[ -n "${pkg}" ]] && packages+=("${pkg}") done < <(translate_packages "$@") if [[ "${#packages[@]}" -eq 0 ]]; then return 0 fi pkg_update_once if is_debian; then run env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}" elif is_rpm_family; then ensure_epel_repo run dnf -y install "${packages[@]}" else fail "Unsupported package manager for OS $(os_id)." fi } pkg_remove_purge() { if is_debian; then run env DEBIAN_FRONTEND=noninteractive apt-get remove -y --purge "$@" elif is_rpm_family; then run dnf -y remove "$@" else fail "Unsupported package manager for OS $(os_id)." fi } ensure_epel_repo() { if ! is_rpm_family; then return fi if rpm -q epel-release >/dev/null 2>&1; then return fi run dnf -y install dnf-plugins-core epel-release run dnf -y config-manager --set-enabled crb || true DNF_UPDATED= } # Where to install the JinjaTurtle virtualenv and the PATH dir we symlink its # console script into. /usr/local/bin is already on root's PATH on Debian/RPM. JINJATURTLE_VENV_DIR="${ENROLL_TEST_JINJATURTLE_VENV:-${WORK_DIR}/jinjaturtle-venv}" JINJATURTLE_BIN_DIR="${ENROLL_TEST_JINJATURTLE_BIN_DIR:-/usr/local/bin}" # Build + install JinjaTurtle from a git checkout (default) instead of the # apt.mig5.net package repository, which is not always reachable. # # Override knobs (all optional): # ENROLL_TEST_JINJATURTLE_METHOD git (default) | apt -- install strategy # ENROLL_TEST_JINJATURTLE_SOURCE path to an existing local checkout; when # set, no clone happens (offline/CI friendly) # ENROLL_TEST_JINJATURTLE_GIT git URL to clone # (default: https://git.mig5.net/mig5/jinjaturtle) # ENROLL_TEST_JINJATURTLE_REF branch/tag/commit to check out (default: main) # ENROLL_TEST_JINJATURTLE_VENV venv dir (default: under the test work dir) # ENROLL_TEST_JINJATURTLE_BIN_DIR PATH dir to expose the script in # (default: /usr/local/bin) ensure_jinjaturtle() { section "Setup: JinjaTurtle" if command -v jinjaturtle >/dev/null 2>&1; then printf 'jinjaturtle already available at: %s\n' "$(command -v jinjaturtle)" return fi local method="${ENROLL_TEST_JINJATURTLE_METHOD:-git}" case "${method}" in apt) ensure_jinjaturtle_apt ;; git) ensure_jinjaturtle_from_git ;; *) fail "Unknown ENROLL_TEST_JINJATURTLE_METHOD='${method}' (expected 'git' or 'apt')." ;; esac } # Legacy apt.mig5.net package install, kept as an opt-in fallback # (ENROLL_TEST_JINJATURTLE_METHOD=apt) for hosts where that repo is reachable. ensure_jinjaturtle_apt() { if is_debian; then pkg_install ca-certificates curl gnupg lsb-release run mkdir -p /usr/share/keyrings run bash -c "curl -fsSL https://mig5.net/static/mig5.asc | gpg --dearmor --yes -o /usr/share/keyrings/mig5.gpg" local codename codename="$(lsb_release -cs)" run bash -c "printf '%s\n' 'deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net ${codename} main' > /etc/apt/sources.list.d/mig5.list" run apt-get update APT_UPDATED=1 run env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends jinjaturtle elif is_rpm_family; then printf 'Skipping JinjaTurtle apt package integration on RPM-family CI;\n' return else fail "Unsupported OS for JinjaTurtle apt install: $(os_id)." fi } ensure_jinjaturtle_from_git() { # Tools we need to fetch and build from source. if is_debian; then pkg_install ca-certificates git python3 python3-venv elif is_rpm_family; then pkg_install ca-certificates git python3 else fail "Unsupported OS for JinjaTurtle git build: $(os_id)." fi local src="${ENROLL_TEST_JINJATURTLE_SOURCE:-}" local cloned=0 if [[ -z "${src}" ]]; then local git_url="${ENROLL_TEST_JINJATURTLE_GIT:-https://git.mig5.net/mig5/jinjaturtle}" local git_ref="${ENROLL_TEST_JINJATURTLE_REF:-main}" src="${WORK_DIR}/jinjaturtle-src" rm -rf "${src}" run git clone --depth 1 --branch "${git_ref}" "${git_url}" "${src}" \ || run git clone "${git_url}" "${src}" cloned=1 else [[ -d "${src}" ]] || fail "ENROLL_TEST_JINJATURTLE_SOURCE='${src}' is not a directory." printf 'Using existing JinjaTurtle checkout: %s\n' "${src}" fi # Build into a dedicated venv so the system Python stays clean, then expose # the console script on PATH. Enroll only needs shutil.which("jinjaturtle") # to succeed, which a symlink into /usr/local/bin satisfies. rm -rf "${JINJATURTLE_VENV_DIR}" run python3 -m venv "${JINJATURTLE_VENV_DIR}" run "${JINJATURTLE_VENV_DIR}/bin/pip" install --upgrade pip run "${JINJATURTLE_VENV_DIR}/bin/pip" install "${src}" local script="${JINJATURTLE_VENV_DIR}/bin/jinjaturtle" [[ -x "${script}" ]] || fail "JinjaTurtle build did not produce ${script}." run mkdir -p "${JINJATURTLE_BIN_DIR}" run ln -sf "${script}" "${JINJATURTLE_BIN_DIR}/jinjaturtle" # Make sure the chosen bin dir is actually on PATH for the rest of this run. case ":${PATH}:" in *":${JINJATURTLE_BIN_DIR}:"*) ;; *) export PATH="${JINJATURTLE_BIN_DIR}:${PATH}" ;; esac hash -r 2>/dev/null || true command -v jinjaturtle >/dev/null 2>&1 \ || fail "jinjaturtle was built but is not on PATH (looked in ${JINJATURTLE_BIN_DIR})." printf 'jinjaturtle installed from %s%s and available at: %s\n' \ "${src}" \ "$([[ "${cloned}" -eq 1 ]] && printf ' (cloned)')" \ "$(command -v jinjaturtle)" } require_cmd() { local cmd="$1" local hint="$2" if ! command -v "${cmd}" >/dev/null 2>&1; then fail "Required command '${cmd}' was not found. ${hint}" fi } ensure_ansible() { ensure_epel_repo if ! command -v ansible-playbook >/dev/null 2>&1 || ! command -v ansible-lint >/dev/null 2>&1; then pkg_install ansible ansible-lint fi require_cmd ansible-playbook "Install the ansible/ansible-core package." require_cmd ansible-lint "Install the ansible-lint package." } run_pytests() { section "Python unit tests" cd "${PROJECT_ROOT}" run poetry run python -m pytest -vvvv --cov=enroll --cov-report=term-missing --disable-warnings } prepare_harvest_fixture() { section "Common harvest fixture and CLI smoke checks" pkg_install jq apache2 cat >"${JINJATURTLE_FIXTURE}" <<'EOF' [enroll_tests] enabled = true answer = 42 EOF cd "${PROJECT_ROOT}" rm -rf "${BUNDLE_DIR}" "${BUNDLE_DIFF_DIR}" run poetry run enroll harvest --out "${BUNDLE_DIR}" --include-path "${JINJATURTLE_FIXTURE}" run poetry run enroll explain "${BUNDLE_DIR}" run bash -c "poetry run enroll explain '${BUNDLE_DIR}' --format json | jq" run poetry run enroll validate --fail-on-warnings "${BUNDLE_DIR}" pkg_install cowsay run poetry run enroll harvest --out "${BUNDLE_DIFF_DIR}" --include-path "${JINJATURTLE_FIXTURE}" run poetry run enroll validate --fail-on-warnings "${BUNDLE_DIFF_DIR}" run bash -c "poetry run enroll diff --old '${BUNDLE_DIR}' --new '${BUNDLE_DIFF_DIR}' --format json | jq" pkg_remove_purge cowsay } assert_template_files() { local manifest_dir="$1" local extension="$2" local expected="$3" local label="$4" local found found="$(find "${manifest_dir}" -type f -name "*.${extension}" -print -quit)" if [[ "${expected}" == "present" ]]; then if [[ -z "${found}" ]]; then fail "Expected ${label} to contain at least one .${extension} template, but none were found." fi printf 'Found expected .%s template in %s: %s\n' "${extension}" "${label}" "${found}" else if [[ -n "${found}" ]]; then fail "Expected ${label} to contain no .${extension} templates, but found ${found}." fi printf 'Confirmed no .%s templates in %s.\n' "${extension}" "${label}" fi } run_ansible_jinjaturtle_variant() { local out_dir="$1" local expected="$2" local label="$3" shift 3 ensure_ansible cd "${PROJECT_ROOT}" rm -rf "${out_dir}" run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${out_dir}" "$@" assert_template_files "${out_dir}" "j2" "${expected}" "${label}" ansible-galaxy install -r "${out_dir}/requirements.yml" run ansible-lint "${out_dir}" cd "${out_dir}" run ansible-playbook playbook.yml -i "localhost," -c local --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}" } run_jinjaturtle_manifest_tests() { ensure_jinjaturtle require_cmd jinjaturtle "Install JinjaTurtle before running the JinjaTurtle integration matrix." section "Ansible JinjaTurtle manifest noop tests" run_ansible_jinjaturtle_variant "${ANSIBLE_JINJATURTLE_DIR}" present "Ansible with JinjaTurtle on PATH" run_ansible_jinjaturtle_variant "${ANSIBLE_NO_JINJATURTLE_DIR}" absent "Ansible with --no-jinjaturtle" --no-jinjaturtle } run_ansible_noop_tests() { section "Ansible manifest noop tests" ensure_ansible cd "${PROJECT_ROOT}" rm -rf "${ANSIBLE_DIR}" "${ANSIBLE_NO_COMMON_DIR}" "${ANSIBLE_FQDN_DIR}" run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${ANSIBLE_DIR}" ansible-galaxy install -r "${ANSIBLE_DIR}/requirements.yml" run ansible-lint "${ANSIBLE_DIR}" cd "${ANSIBLE_DIR}" run ansible-playbook playbook.yml -i "localhost," -c local --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}" cd "${PROJECT_ROOT}" run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${ANSIBLE_NO_COMMON_DIR}" --no-common-roles ansible-galaxy install -r "${ANSIBLE_NO_COMMON_DIR}/requirements.yml" cd "${ANSIBLE_NO_COMMON_DIR}" run ansible-playbook playbook.yml -i "localhost," -c local --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}" cd "${PROJECT_ROOT}" run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${ANSIBLE_FQDN_DIR}" --fqdn "${TEST_FQDN}" ansible-galaxy install -r "${ANSIBLE_FQDN_DIR}/requirements.yml" cd "${ANSIBLE_FQDN_DIR}" run ansible-playbook "playbooks/${TEST_FQDN}.yml" -i inventory/hosts.ini -c local --limit "${TEST_FQDN}" --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}" } main() { require_root require_supported_ci_os run_pytests prepare_harvest_fixture configure_ansible_playbook_extra_args run_ansible_noop_tests run_jinjaturtle_manifest_tests } main "$@"