mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).
This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.
Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:
* 7 `src:` values built from a role variable holding a bare filename
(svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
The variable doubles as the destination name inside the container, so the
language segment belongs at the call site, not in the variable.
* `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
18 roles ship exactly that and have no template fallback: the play would
have died on each.
* `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
interface now reads `files/javascript/javascript.js`.
* `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
path through `path_absolute(... 'files', ...)`.
* 4 `ansible.builtin.script:` invocations naming a bare command
(pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
* `dev-python/files/shell/install.sh` pointed at a sibling role's script.
Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
359 lines
12 KiB
Bash
Executable File
359 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
MODE="${1:-ensure}"
|
|
case "${MODE}" in
|
|
ensure | print) ;;
|
|
*)
|
|
echo "Usage: $0 [ensure|print]" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
if [[ "${MODE}" == "print" ]]; then
|
|
exec 3>&1
|
|
exec 1>&2
|
|
fi
|
|
|
|
if [[ ! -f /etc/os-release ]]; then
|
|
echo "[ERROR] /etc/os-release not found; unsupported system." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# shellcheck disable=SC1091
|
|
. /etc/os-release
|
|
ID_LIKE="${ID_LIKE:-}"
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)"
|
|
DOCKER_APT_SANITIZER="${REPO_ROOT}/roles/sys-svc-container/files/shell/apt_repo.sh"
|
|
|
|
log() {
|
|
printf '>>> [dev-python] %s\n' "$*"
|
|
}
|
|
|
|
run_privileged() {
|
|
if [[ "${EUID}" -eq 0 ]]; then
|
|
"$@"
|
|
elif command -v sudo >/dev/null 2>&1; then
|
|
sudo "$@"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
can_run_privileged() {
|
|
if [[ "${EUID}" -eq 0 ]]; then
|
|
return 0
|
|
fi
|
|
command -v sudo >/dev/null 2>&1 || return 1
|
|
sudo -n true >/dev/null 2>&1
|
|
}
|
|
|
|
need_privileged_or_fail() {
|
|
if can_run_privileged; then
|
|
return 0
|
|
fi
|
|
echo "[ERROR] Root privileges are required (sudo unavailable)." >&2
|
|
exit 1
|
|
}
|
|
|
|
sanitize_debian_docker_apt_sources() {
|
|
local distro_id="$1"
|
|
|
|
if [[ -v SKIP_DOCKER_APT_SANITIZER && "${SKIP_DOCKER_APT_SANITIZER}" == "1" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
run_privileged bash "${DOCKER_APT_SANITIZER}" sanitize-sources "${distro_id}" 1 >/dev/null
|
|
}
|
|
|
|
find_highest_apt_python_pkg() {
|
|
apt-cache pkgnames 2>/dev/null \
|
|
| grep -E '^python3\.[0-9]+$' \
|
|
| sort -V \
|
|
| tail -n1
|
|
}
|
|
|
|
find_highest_rpm_python_pkg() {
|
|
local pm="$1"
|
|
{
|
|
run_privileged "${pm}" -q list installed "python3.[0-9]*" 2>/dev/null || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
run_privileged "${pm}" -q list --available "python3.[0-9]*" 2>/dev/null || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
} \
|
|
| awk '/^python3\.[0-9]+\./ {pkg=$1; sub(/\.[^.]+$/, "", pkg); print pkg}' \
|
|
| sort -Vu \
|
|
| tail -n1
|
|
}
|
|
|
|
rpm_pkg_exists() {
|
|
local pm="$1"
|
|
local pkg="$2"
|
|
|
|
run_privileged "${pm}" -q list --available "${pkg}" >/dev/null 2>&1 \
|
|
|| run_privileged "${pm}" -q list installed "${pkg}" >/dev/null 2>&1
|
|
}
|
|
|
|
install_versioned_rpm_python_and_pip() {
|
|
local pm="$1"
|
|
local py_pkg="$2"
|
|
local pip_pkg="${py_pkg}-pip"
|
|
|
|
run_privileged "${pm}" -y install "${py_pkg}" || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
if rpm_pkg_exists "${pm}" "${pip_pkg}"; then
|
|
run_privileged "${pm}" -y install "${pip_pkg}" || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
else
|
|
log "Skipping unavailable package ${pip_pkg}; using generic python3-pip fallback if present."
|
|
fi
|
|
}
|
|
|
|
install_python_packages() {
|
|
need_privileged_or_fail
|
|
|
|
case "${ID}" in
|
|
arch | manjaro)
|
|
run_privileged pacman-key --init
|
|
run_privileged pacman -Syu --noconfirm --needed python python-pip
|
|
run_privileged pacman -Scc --noconfirm || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
;;
|
|
|
|
debian | ubuntu)
|
|
local apt_best_pkg=""
|
|
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
sanitize_debian_docker_apt_sources "${ID}"
|
|
run_privileged apt-get update
|
|
run_privileged apt-get install -y --no-install-recommends python3 python3-pip
|
|
apt_best_pkg="$(find_highest_apt_python_pkg || true)"
|
|
if [[ "${apt_best_pkg}" =~ ^python3\.([0-9]+)$ ]] && (( BASH_REMATCH[1] >= 11 )); then
|
|
run_privileged apt-get install -y --no-install-recommends "${apt_best_pkg}" || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
if apt-cache show "${apt_best_pkg}-venv" >/dev/null 2>&1; then
|
|
run_privileged apt-get install -y --no-install-recommends "${apt_best_pkg}-venv" || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
fi
|
|
fi
|
|
run_privileged rm -rf /var/lib/apt/lists/*
|
|
;;
|
|
|
|
fedora)
|
|
local dnf_best_pkg=""
|
|
|
|
run_privileged dnf -y install python3 python3-pip
|
|
dnf_best_pkg="$(find_highest_rpm_python_pkg dnf || true)"
|
|
if [[ "${dnf_best_pkg}" =~ ^python3\.([0-9]+)$ ]] && (( BASH_REMATCH[1] >= 11 )); then
|
|
install_versioned_rpm_python_and_pip dnf "${dnf_best_pkg}"
|
|
fi
|
|
run_privileged dnf -y clean all || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
run_privileged rm -rf /var/cache/dnf || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
;;
|
|
|
|
centos | rhel)
|
|
local rpm_best_pkg=""
|
|
|
|
if command -v dnf >/dev/null 2>&1; then
|
|
PM=dnf
|
|
else
|
|
PM=yum
|
|
fi
|
|
run_privileged "${PM}" -y install python3 python3-pip || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
rpm_best_pkg="$(find_highest_rpm_python_pkg "${PM}" || true)"
|
|
if [[ "${rpm_best_pkg}" =~ ^python3\.([0-9]+)$ ]] && (( BASH_REMATCH[1] >= 11 )); then
|
|
install_versioned_rpm_python_and_pip "${PM}" "${rpm_best_pkg}"
|
|
fi
|
|
run_privileged "${PM}" -y clean all || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
run_privileged rm -rf "/var/cache/${PM}" || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
;;
|
|
|
|
*)
|
|
local rpm_like_best_pkg=""
|
|
|
|
if [[ "${ID_LIKE}" =~ (rhel|centos) ]]; then
|
|
if command -v dnf >/dev/null 2>&1; then
|
|
PM=dnf
|
|
else
|
|
PM=yum
|
|
fi
|
|
run_privileged "${PM}" -y install python3 python3-pip || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
rpm_like_best_pkg="$(find_highest_rpm_python_pkg "${PM}" || true)"
|
|
if [[ "${rpm_like_best_pkg}" =~ ^python3\.([0-9]+)$ ]] && (( BASH_REMATCH[1] >= 11 )); then
|
|
install_versioned_rpm_python_and_pip "${PM}" "${rpm_like_best_pkg}"
|
|
fi
|
|
run_privileged "${PM}" -y clean all || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
run_privileged rm -rf "/var/cache/${PM}" || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
else
|
|
echo "[ERROR] Unsupported distro for Python install: ID=${ID} ID_LIKE=${ID_LIKE}" >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
python_is_311_or_higher() {
|
|
local bin="$1"
|
|
command -v "${bin}" >/dev/null 2>&1 || return 1
|
|
"${bin}" - <<'PY' >/dev/null 2>&1
|
|
import sys
|
|
raise SystemExit(0 if sys.version_info >= (3, 11) else 1)
|
|
PY
|
|
}
|
|
|
|
python_has_pip_module() {
|
|
local bin="$1"
|
|
command -v "${bin}" >/dev/null 2>&1 || return 1
|
|
"${bin}" -m pip --version >/dev/null 2>&1
|
|
}
|
|
|
|
python_minor_version() {
|
|
local bin="$1"
|
|
"${bin}" - <<'PY' 2>/dev/null
|
|
import sys
|
|
print(f"{sys.version_info[0]}.{sys.version_info[1]}")
|
|
PY
|
|
}
|
|
|
|
canonicalize_python_candidate() {
|
|
local bin="$1"
|
|
local resolved=""
|
|
|
|
if resolved="$(readlink -f -- "${bin}" 2>/dev/null)" && [[ -x "${resolved}" ]]; then
|
|
printf '%s\n' "${resolved}"
|
|
return 0
|
|
fi
|
|
|
|
printf '%s\n' "${bin}"
|
|
}
|
|
|
|
pick_python_bin() {
|
|
local bin candidate name version
|
|
local best_version="" best_bin=""
|
|
local best_pip_version="" best_pip_bin=""
|
|
for bin in \
|
|
/usr/local/bin/python3.* \
|
|
/usr/bin/python3.* \
|
|
/usr/local/bin/python3 \
|
|
/usr/bin/python3 \
|
|
python3; do
|
|
[[ -x "${bin}" ]] || continue
|
|
candidate="$(canonicalize_python_candidate "${bin}")"
|
|
[[ -x "${candidate}" ]] || continue
|
|
name="$(basename "${candidate}")"
|
|
if [[ "${name}" == python3.* ]] && [[ ! "${name}" =~ ^python3\.[0-9]+$ ]]; then
|
|
continue
|
|
fi
|
|
if ! python_is_311_or_higher "${candidate}"; then
|
|
continue
|
|
fi
|
|
version="$(python_minor_version "${candidate}")"
|
|
[[ -n "${version}" ]] || continue
|
|
if [[ -z "${best_version}" ]] || [[ "$(printf '%s\n%s\n' "${best_version}" "${version}" | sort -V | tail -n1)" == "${version}" ]]; then
|
|
best_version="${version}"
|
|
best_bin="${candidate}"
|
|
fi
|
|
if python_has_pip_module "${candidate}"; then
|
|
if [[ -z "${best_pip_version}" ]] || [[ "$(printf '%s\n%s\n' "${best_pip_version}" "${version}" | sort -V | tail -n1)" == "${version}" ]]; then
|
|
best_pip_version="${version}"
|
|
best_pip_bin="${candidate}"
|
|
fi
|
|
fi
|
|
done
|
|
if [[ -n "${best_pip_bin}" ]]; then
|
|
printf '%s\n' "${best_pip_bin}"
|
|
return 0
|
|
fi
|
|
[[ -n "${best_bin}" ]] || return 1
|
|
printf '%s\n' "${best_bin}"
|
|
}
|
|
|
|
configure_defaults() {
|
|
local pybin="$1"
|
|
local pyver
|
|
local versioned_pip
|
|
local pybin_resolved=""
|
|
|
|
need_privileged_or_fail
|
|
|
|
if pybin_resolved="$(readlink -f -- "${pybin}" 2>/dev/null)" && [[ -x "${pybin_resolved}" ]]; then
|
|
pybin="${pybin_resolved}"
|
|
fi
|
|
|
|
if [[ "${pybin}" == "/usr/local/bin/python3" ]]; then
|
|
echo "[ERROR] Refusing to link /usr/local/bin/python3 to itself." >&2
|
|
exit 1
|
|
fi
|
|
|
|
pyver="$("${pybin}" -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")')"
|
|
versioned_pip="/usr/bin/pip${pyver}"
|
|
|
|
run_privileged install -d /usr/local/bin
|
|
run_privileged ln -sfn "${pybin}" /usr/local/bin/python3
|
|
run_privileged ln -sfn /usr/local/bin/python3 /usr/local/bin/python
|
|
|
|
if [[ -x "${versioned_pip}" ]]; then
|
|
run_privileged ln -sfn "${versioned_pip}" /usr/local/bin/pip3
|
|
else
|
|
if ! "${pybin}" -m pip --version >/dev/null 2>&1; then
|
|
run_privileged "${pybin}" -m ensurepip --upgrade >/dev/null 2>&1 || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
fi
|
|
if "${pybin}" -m pip --version >/dev/null 2>&1; then
|
|
run_privileged tee /usr/local/bin/pip3 >/dev/null <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
exec /usr/local/bin/python3 -m pip "$@"
|
|
EOF
|
|
run_privileged chmod 0755 /usr/local/bin/pip3
|
|
elif [[ -x /usr/bin/pip3 ]] && /usr/bin/pip3 --version 2>/dev/null | grep -q "python ${pyver}"; then
|
|
run_privileged ln -sfn /usr/bin/pip3 /usr/local/bin/pip3
|
|
else
|
|
echo "[ERROR] Could not provide pip3 for Python ${pyver}." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
run_privileged ln -sfn /usr/local/bin/pip3 /usr/local/bin/pip
|
|
|
|
}
|
|
|
|
resolve_python_bin_or_fail() {
|
|
if PYBIN="$(pick_python_bin)"; then
|
|
return 0
|
|
fi
|
|
echo "[ERROR] No Python >= 3.11 found after installation attempt." >&2
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
python3 --version || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
fi
|
|
exit 1
|
|
}
|
|
|
|
ensure_python_bin() {
|
|
log "Ensuring highest available Python >=3.11 for ID=${ID} ID_LIKE=${ID_LIKE}"
|
|
install_python_packages
|
|
hash -r || true # nocheck: shell-or-true -- grandfathered: worked in practice; TODO: sharpen to catch only the exact tolerated error
|
|
resolve_python_bin_or_fail
|
|
}
|
|
|
|
resolve_or_install_python_bin() {
|
|
if PYBIN="$(pick_python_bin)"; then
|
|
return 0
|
|
fi
|
|
ensure_python_bin
|
|
}
|
|
|
|
if [[ "${MODE}" == "ensure" ]]; then
|
|
if PYBIN="$(pick_python_bin)"; then
|
|
if ! can_run_privileged; then
|
|
log "Using existing Python without privileged system-wide symlink updates: $("${PYBIN}" --version)"
|
|
if "${PYBIN}" -m pip --version >/dev/null 2>&1; then
|
|
log "Existing pip: $("${PYBIN}" -m pip --version)"
|
|
exit 0
|
|
fi
|
|
fi
|
|
else
|
|
ensure_python_bin
|
|
fi
|
|
|
|
configure_defaults "${PYBIN}"
|
|
log "Default python: $(python3 --version)"
|
|
log "Default pip: $(pip3 --version)"
|
|
else
|
|
resolve_or_install_python_bin
|
|
printf '%s\n' "${PYBIN}" >&3
|
|
fi
|