mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
Fedora 45 defaults to Python 3.15. ansible-core 2.21, which the unpinned `ansible` extra still resolves to (ansible 14.5.0 pins ~=2.21.5), calls dataclasses._is_type, which 3.15 removed, so the Workspace (fedora:latest) job died before the first playbook (run 41491308729). install.sh now picks and installs the highest Python between PYTHON_MIN_MINOR and PYTHON_MAX_MINOR (3.11 to 3.14) on apt, rpm and pick_python_bin, and fails loudly when only newer interpreters exist. On Fedora 45 it installs python3.14. Raise PYTHON_MAX_MINOR once ansible ships ansible-core 2.22. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
378 lines
12 KiB
Bash
Executable File
378 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
PYTHON_MIN_MINOR=11
|
|
PYTHON_MAX_MINOR=14
|
|
|
|
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
|
|
}
|
|
|
|
python_minor_within_bounds() {
|
|
local version="$1"
|
|
[[ "${version}" =~ ^3\.([0-9]+)$ ]] || return 1
|
|
(( BASH_REMATCH[1] >= PYTHON_MIN_MINOR && BASH_REMATCH[1] <= PYTHON_MAX_MINOR ))
|
|
}
|
|
|
|
keep_python_pkgs_within_bounds() {
|
|
local pkg
|
|
while IFS= read -r pkg; do
|
|
if python_minor_within_bounds "${pkg#python}"; then
|
|
printf '%s\n' "${pkg}"
|
|
fi
|
|
done
|
|
}
|
|
|
|
find_highest_apt_python_pkg() {
|
|
apt-cache pkgnames 2>/dev/null \
|
|
| grep -E '^python3\.[0-9]+$' \
|
|
| keep_python_pkgs_within_bounds \
|
|
| 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}' \
|
|
| keep_python_pkgs_within_bounds \
|
|
| 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 [[ -n "${apt_best_pkg}" ]]; 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 [[ -n "${dnf_best_pkg}" ]]; 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 [[ -n "${rpm_best_pkg}" ]]; 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 [[ -n "${rpm_like_best_pkg}" ]]; 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_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_best_python() {
|
|
local bin candidate name version
|
|
local best_version="" best_bin=""
|
|
local best_pip_version="" best_pip_bin=""
|
|
for bin in "$@"; 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
|
|
version="$(python_minor_version "${candidate}")" || continue
|
|
python_minor_within_bounds "${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}"
|
|
}
|
|
|
|
pick_python_bin() {
|
|
pick_best_python \
|
|
/usr/local/bin/python3.* \
|
|
/usr/bin/python3.* \
|
|
/usr/local/bin/python3 \
|
|
/usr/bin/python3 \
|
|
python3
|
|
}
|
|
|
|
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.${PYTHON_MIN_MINOR} to 3.${PYTHON_MAX_MINOR} found after installation attempt; newer interpreters are rejected." >&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.${PYTHON_MIN_MINOR} to 3.${PYTHON_MAX_MINOR} 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
|
|
}
|
|
|
|
main() {
|
|
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:-}"
|
|
|
|
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
|
|
}
|
|
|
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
main "$@"
|
|
fi
|