Files
computer-playbook/roles/dev-python/files/shell/install.sh
T
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
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>
2026-08-22 01:31:09 +02:00

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