512 lines
16 KiB
Bash
Executable file
512 lines
16 KiB
Bash
Executable file
#!/bin/bash
|
|
|
|
set -Eeuo pipefail
|
|
|
|
# These tests intentionally run as root and exercise Enroll's root-output
|
|
# safety checks. Keep test-created directories private even on Forgejo/Docker
|
|
# images that default to a permissive umask such as 0002.
|
|
umask 077
|
|
|
|
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}"
|
|
chmod 700 "${WORK_DIR}" || true
|
|
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:]' </proc/1/comm || true
|
|
return
|
|
fi
|
|
if command -v ps >/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
|
|
}
|
|
|
|
preferred_python_bin() {
|
|
if [[ -n "${ENROLL_TEST_PYTHON_BIN:-}" ]]; then
|
|
printf '%s' "${ENROLL_TEST_PYTHON_BIN}"
|
|
return
|
|
fi
|
|
if [[ -n "${PYTHON_BIN:-}" ]]; then
|
|
printf '%s' "${PYTHON_BIN}"
|
|
return
|
|
fi
|
|
if command -v python3.11 >/dev/null 2>&1; then
|
|
printf '%s' "python3.11"
|
|
return
|
|
fi
|
|
printf '%s' "python3"
|
|
}
|
|
|
|
require_python_for_jinjaturtle() {
|
|
local py_bin
|
|
py_bin="$(preferred_python_bin)"
|
|
command -v "${py_bin}" >/dev/null 2>&1 \
|
|
|| fail "Python interpreter '${py_bin}' was not found; set PYTHON_BIN or ENROLL_TEST_PYTHON_BIN."
|
|
"${py_bin}" - <<'PY'
|
|
import sys
|
|
if sys.version_info < (3, 10):
|
|
raise SystemExit(
|
|
f"Python {sys.version.split()[0]} is too old for JinjaTurtle; "
|
|
"install/use Python >= 3.10 (for AlmaLinux 9, set PYTHON_BIN=python3.11)."
|
|
)
|
|
PY
|
|
printf '%s' "${py_bin}"
|
|
}
|
|
|
|
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.11 python3.11-pip
|
|
else
|
|
fail "Unsupported OS for JinjaTurtle git build: $(os_id)."
|
|
fi
|
|
|
|
local jt_python
|
|
jt_python="$(require_python_for_jinjaturtle)"
|
|
|
|
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 "${jt_python}" -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}"
|
|
|
|
# Enforce decent perms on /root subdirs
|
|
find /root -type d -print0 | xargs -0 -r chmod 755
|
|
|
|
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 "$@"
|