#!/usr/bin/env bash # shellcheck shell=bash set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" ACTIONLINT_VERSION="${ACTIONLINT_VERSION:-latest}" ACTIONLINT_INSTALL_DIR="${ACTIONLINT_INSTALL_DIR:-/usr/local/bin}" SHFMT_VERSION="${SHFMT_VERSION:-latest}" SHFMT_INSTALL_DIR="${SHFMT_INSTALL_DIR:-/usr/local/bin}" RUFF_PIP_SPEC="${RUFF_PIP_SPEC:-ruff}" run_privileged() { if [[ "${EUID}" -eq 0 ]] || ! command -v sudo >/dev/null 2>&1; then "$@" else sudo "$@" fi } log() { printf '%s\n' "$*" } warn() { printf '%s\n' "$*" >&2 } detect_python_bin() { local python_bin="${PYTHON:-python3}" if command -v "${python_bin}" >/dev/null 2>&1; then printf '%s\n' "${python_bin}" return 0 fi if command -v python3 >/dev/null 2>&1; then printf 'python3\n' return 0 fi if command -v python >/dev/null 2>&1; then printf 'python\n' return 0 fi warn "Need python, python3, curl, or wget." return 1 } download_file() { local url="$1" local output="$2" local python_bin if command -v curl >/dev/null 2>&1; then curl -fsSL "${url}" -o "${output}" return 0 fi if command -v wget >/dev/null 2>&1; then wget -qO "${output}" "${url}" return 0 fi python_bin="$(detect_python_bin)" || { warn "Need curl, wget, or python to download ${url}." return 1 } "${python_bin}" - "${url}" "${output}" <<'PY' import pathlib import sys from urllib.request import urlopen url, output = sys.argv[1], sys.argv[2] with urlopen(url) as response: pathlib.Path(output).write_bytes(response.read()) PY } resolve_latest_actionlint_version() { resolve_latest_github_release_version "https://github.com/rhysd/actionlint/releases/latest" } resolve_latest_github_release_version() { local latest_url="$1" local final_url="" local python_bin if command -v curl >/dev/null 2>&1; then final_url="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "${latest_url}")" elif command -v wget >/dev/null 2>&1; then final_url="$( wget -q --server-response --max-redirect=0 -O /dev/null "${latest_url}" 2>&1 | sed -n 's/^[[:space:]]*Location: //p' | tr -d '\r' | tail -n1 )" else python_bin="$(detect_python_bin)" || return 1 final_url="$( "${python_bin}" - "${latest_url}" <<'PY' import sys from urllib.request import urlopen with urlopen(sys.argv[1]) as response: print(response.geturl()) PY )" fi final_url="${final_url%/}" if [[ -z "${final_url}" ]]; then warn "Failed to resolve latest actionlint release URL." return 1 fi if [[ "${final_url}" == "${latest_url}" ]]; then warn "Latest actionlint release URL did not resolve to a versioned tag." return 1 fi local latest_tag="${final_url##*/}" latest_tag="${latest_tag#v}" if [[ -z "${latest_tag}" || "${latest_tag}" == "${final_url}" || "${latest_tag}" == "latest" ]]; then warn "Failed to determine latest actionlint version from ${final_url}." return 1 fi printf '%s\n' "${latest_tag}" } resolve_actionlint_version() { local requested="${ACTIONLINT_VERSION#v}" if [[ "${requested}" != "latest" ]]; then printf '%s\n' "${requested}" return 0 fi resolve_latest_actionlint_version } resolve_latest_shfmt_version() { resolve_latest_github_release_version "https://github.com/mvdan/sh/releases/latest" } resolve_shfmt_version() { local requested="${SHFMT_VERSION#v}" if [[ "${requested}" != "latest" ]]; then printf '%s\n' "${requested}" return 0 fi resolve_latest_shfmt_version } detect_shfmt_os() { case "$(uname -s)" in Linux) printf 'linux\n' ;; Darwin) printf 'darwin\n' ;; *) warn "Unsupported OS for shfmt prebuilt binary: $(uname -s)" return 1 ;; esac } detect_shfmt_arch() { case "$(uname -m)" in x86_64 | amd64) printf 'amd64\n' ;; i386 | i486 | i586 | i686) printf '386\n' ;; aarch64 | arm64) printf 'arm64\n' ;; armv6l | armv7l) printf 'arm\n' ;; *) warn "Unsupported architecture for shfmt prebuilt binary: $(uname -m)" return 1 ;; esac } detect_actionlint_os() { case "$(uname -s)" in Linux) printf 'linux\n' ;; Darwin) printf 'darwin\n' ;; FreeBSD) printf 'freebsd\n' ;; *) warn "Unsupported OS for actionlint prebuilt binary: $(uname -s)" return 1 ;; esac } detect_actionlint_arch() { case "$(uname -m)" in x86_64 | amd64) printf 'amd64\n' ;; i386 | i486 | i586 | i686) printf '386\n' ;; aarch64 | arm64) printf 'arm64\n' ;; armv6l | armv7l) printf 'armv6\n' ;; *) warn "Unsupported architecture for actionlint prebuilt binary: $(uname -m)" return 1 ;; esac } ensure_dir_on_path() { local dir="$1" case ":${PATH}:" in *:"${dir}":*) ;; *) export PATH="${dir}:${PATH}" ;; esac } install_with_optional_sudo() { if "$@"; then return 0 fi run_privileged "$@" } install_actionlint_binary() { local version local os local arch local archive_name local url local tmpdir local archive_path version="$(resolve_actionlint_version)" || return 1 os="$(detect_actionlint_os)" || return 1 arch="$(detect_actionlint_arch)" || return 1 archive_name="actionlint_${version}_${os}_${arch}.tar.gz" url="https://github.com/rhysd/actionlint/releases/download/v${version}/${archive_name}" tmpdir="$(mktemp -d)" archive_path="${tmpdir}/${archive_name}" if [[ "${ACTIONLINT_VERSION#v}" == "latest" ]]; then log "Installing latest actionlint (resolved to v${version}) from GitHub releases" else log "Installing actionlint v${version} from GitHub releases" fi if ! download_file "${url}" "${archive_path}"; then warn "Failed to download ${url}" rm -rf "${tmpdir}" return 1 fi if ! tar -xzf "${archive_path}" -C "${tmpdir}"; then warn "Failed to extract ${archive_name}" rm -rf "${tmpdir}" return 1 fi if [[ ! -f "${tmpdir}/actionlint" ]]; then warn "Archive ${archive_name} did not contain an actionlint binary." rm -rf "${tmpdir}" return 1 fi if ! install_with_optional_sudo install -d "${ACTIONLINT_INSTALL_DIR}"; then warn "Failed to create ${ACTIONLINT_INSTALL_DIR}" rm -rf "${tmpdir}" return 1 fi if ! install_with_optional_sudo install -m 0755 "${tmpdir}/actionlint" "${ACTIONLINT_INSTALL_DIR}/actionlint"; then warn "Failed to install actionlint into ${ACTIONLINT_INSTALL_DIR}" rm -rf "${tmpdir}" return 1 fi rm -rf "${tmpdir}" } install_shfmt_binary() { local version local os local arch local asset_name local url local tmpdir local binary_path version="$(resolve_shfmt_version)" || return 1 os="$(detect_shfmt_os)" || return 1 arch="$(detect_shfmt_arch)" || return 1 asset_name="shfmt_v${version}_${os}_${arch}" url="https://github.com/mvdan/sh/releases/download/v${version}/${asset_name}" tmpdir="$(mktemp -d)" binary_path="${tmpdir}/shfmt" if [[ "${SHFMT_VERSION#v}" == "latest" ]]; then log "Installing latest shfmt (resolved to v${version}) from GitHub releases" else log "Installing shfmt v${version} from GitHub releases" fi if ! download_file "${url}" "${binary_path}"; then warn "Failed to download ${url}" rm -rf "${tmpdir}" return 1 fi if ! install_with_optional_sudo install -d "${SHFMT_INSTALL_DIR}"; then warn "Failed to create ${SHFMT_INSTALL_DIR}" rm -rf "${tmpdir}" return 1 fi if ! install_with_optional_sudo install -m 0755 "${binary_path}" "${SHFMT_INSTALL_DIR}/shfmt"; then warn "Failed to install shfmt into ${SHFMT_INSTALL_DIR}" rm -rf "${tmpdir}" return 1 fi ensure_dir_on_path "${SHFMT_INSTALL_DIR}" hash -r rm -rf "${tmpdir}" } ensure_actionlint() { if command -v actionlint >/dev/null 2>&1; then return 0 fi log "Missing command 'actionlint'. Installing official prebuilt binary." install_actionlint_binary || { warn "Installation failed for 'actionlint'." return 1 } ensure_dir_on_path "${ACTIONLINT_INSTALL_DIR}" hash -r command -v actionlint >/dev/null 2>&1 || { warn "Command 'actionlint' is still unavailable after installation." return 1 } } detect_python_scripts_dir() { local python_bin="$1" "${python_bin}" - <<'PY' import sysconfig print(sysconfig.get_path("scripts") or "") PY } detect_python_user_scripts_dir() { local python_bin="$1" "${python_bin}" - <<'PY' import site import sys user_base = site.getuserbase() if not user_base: sys.exit(1) print(f"{user_base}/bin") PY } python_runs_in_venv() { local python_bin="$1" "${python_bin}" - <<'PY' >/dev/null 2>&1 import sys raise SystemExit(0 if sys.prefix != getattr(sys, "base_prefix", sys.prefix) else 1) PY } pip_supports_break_system_packages() { local python_bin="$1" "${python_bin}" -m pip install --help 2>/dev/null | grep -q -- '--break-system-packages' } install_ruff_with_pip() { local python_bin local scripts_dir="" local user_scripts_dir="" local -a pip_args=(install --upgrade "${RUFF_PIP_SPEC}") python_bin="$(detect_python_bin)" || return 1 scripts_dir="$(detect_python_scripts_dir "${python_bin}" || true)" log "Installing Python package '${RUFF_PIP_SPEC}' via ${python_bin} -m pip" if python_runs_in_venv "${python_bin}"; then if ! "${python_bin}" -m pip "${pip_args[@]}"; then return 1 fi elif [[ "${EUID}" -eq 0 ]]; then if ! "${python_bin}" -m pip "${pip_args[@]}"; then if ! pip_supports_break_system_packages "${python_bin}"; then return 1 fi log "Retrying Python package install with --break-system-packages" if ! "${python_bin}" -m pip install --break-system-packages --upgrade "${RUFF_PIP_SPEC}"; then return 1 fi fi else if ! "${python_bin}" -m pip install --user --upgrade "${RUFF_PIP_SPEC}"; then return 1 fi user_scripts_dir="$(detect_python_user_scripts_dir "${python_bin}" || true)" fi if [[ -n "${scripts_dir}" && -d "${scripts_dir}" ]]; then ensure_dir_on_path "${scripts_dir}" fi if [[ -n "${user_scripts_dir}" && -d "${user_scripts_dir}" ]]; then ensure_dir_on_path "${user_scripts_dir}" fi hash -r } detect_package_manager() { if command -v pacman >/dev/null 2>&1; then printf 'pacman\n' elif command -v apt-get >/dev/null 2>&1; then printf 'apt-get\n' elif command -v dnf >/dev/null 2>&1; then printf 'dnf\n' elif command -v yum >/dev/null 2>&1; then printf 'yum\n' elif command -v brew >/dev/null 2>&1; then printf 'brew\n' else return 1 fi } install_package_candidates() { local manager="$1" shift local package case "${manager}" in apt-get) run_privileged apt-get update ;; dnf) run_privileged dnf -y install dnf-plugins-core || true run_privileged dnf -y install epel-release || true ;; yum) run_privileged yum -y install yum-utils || true run_privileged yum -y install epel-release || true ;; esac for package in "$@"; do log "Installing package '${package}' via ${manager}" case "${manager}" in pacman) if run_privileged pacman -Syu --noconfirm --needed "${package}"; then return 0 fi ;; apt-get) if run_privileged apt-get install -y --no-install-recommends "${package}"; then return 0 fi ;; dnf) if run_privileged dnf -y install "${package}"; then return 0 fi ;; yum) if run_privileged yum -y install "${package}"; then return 0 fi ;; brew) if brew install "${package}"; then return 0 fi ;; *) warn "Unsupported package manager: ${manager}" return 1 ;; esac done return 1 } fallback_install_command() { local command_name="$1" case "${command_name}" in ruff) log "Falling back to pip installation for 'ruff'." install_ruff_with_pip ;; shfmt) log "Falling back to official shfmt binary." install_shfmt_binary ;; *) return 1 ;; esac } install_command() { local manager="$1" local command_name="$2" case "${command_name}:${manager}" in ansible-galaxy:pacman | ansible-galaxy:apt-get | ansible-galaxy:dnf | ansible-galaxy:yum | ansible-galaxy:brew | \ ansible-playbook:pacman | ansible-playbook:apt-get | ansible-playbook:dnf | ansible-playbook:yum | ansible-playbook:brew) if [[ "${manager}" == "brew" ]]; then install_package_candidates "${manager}" ansible else install_package_candidates "${manager}" ansible-core ansible fi ;; ruff:pacman | ruff:apt-get | ruff:dnf | ruff:yum | ruff:brew) install_package_candidates "${manager}" ruff ;; shfmt:pacman | shfmt:apt-get | shfmt:dnf | shfmt:yum | shfmt:brew) install_package_candidates "${manager}" shfmt ;; shellcheck:pacman | shellcheck:apt-get | shellcheck:dnf | shellcheck:yum | shellcheck:brew) install_package_candidates "${manager}" shellcheck ;; *) warn "No installer mapping defined for '${command_name}' on '${manager}'." return 1 ;; esac } ensure_command() { local command_name="$1" local install_failed=0 if command -v "${command_name}" >/dev/null 2>&1; then return 0 fi local manager manager="$(detect_package_manager)" || { warn "No supported package manager found to install '${command_name}'." return 1 } log "Missing command '${command_name}'. Attempting installation via ${manager}." if ! install_command "${manager}" "${command_name}"; then install_failed=1 warn "Package-manager installation failed for '${command_name}' via ${manager}." fi if command -v "${command_name}" >/dev/null 2>&1; then return 0 fi if fallback_install_command "${command_name}"; then if command -v "${command_name}" >/dev/null 2>&1; then return 0 fi else ((install_failed)) || warn "No fallback installer available for '${command_name}'." fi command -v "${command_name}" >/dev/null 2>&1 || { warn "Command '${command_name}' is still unavailable after installation." return 1 } } ensure_required_ansible_collections() { local collections_base_dir="${HOME}/.ansible/collections" local collections_dir="${collections_base_dir}/ansible_collections" local missing=() local max_attempts=5 local attempt=1 local sleep_time [[ -d "${collections_dir}/community/general" ]] || missing+=(community.general) [[ -d "${collections_dir}/hetzner/hcloud" ]] || missing+=(hetzner.hcloud) [[ -d "${collections_dir}/kewlfft/aur" ]] || missing+=(kewlfft.aur) if [[ "${#missing[@]}" -eq 0 ]]; then return 0 fi while true; do log "Installing missing Ansible collections: ${missing[*]} (attempt ${attempt}/${max_attempts})" if ansible-galaxy collection install \ -r "${REPO_ROOT}/requirements/requirements.galaxy.yml" \ -p "${collections_base_dir}" \ --force-with-deps; then return 0 fi warn "Galaxy install failed on attempt ${attempt}. Trying git fallback." if ansible-galaxy collection install \ -r "${REPO_ROOT}/requirements/requirements.git.yml" \ -p "${collections_base_dir}" \ --force-with-deps; then return 0 fi if ((attempt >= max_attempts)); then warn "Unable to install required Ansible collections." return 1 fi sleep_time=$((60 + RANDOM % 61)) warn "Retrying collection installation in ${sleep_time}s." sleep "${sleep_time}" ((attempt++)) done } install_action_tools() { ensure_actionlint } install_ansible_tools() { ensure_command ansible-playbook ensure_command ansible-galaxy ensure_required_ansible_collections } install_python_tools() { ensure_command shfmt ensure_command ruff } install_shellcheck_tools() { ensure_command shellcheck } install_requested_group() { local group="$1" case "${group}" in all) install_action_tools install_ansible_tools install_python_tools install_shellcheck_tools ;; action) install_action_tools ;; ansible) install_ansible_tools ;; python) install_python_tools ;; shellcheck) install_shellcheck_tools ;; *) warn "Usage: $0 [all|action|ansible|python|shellcheck]..." return 2 ;; esac } main() { local groups=("$@") local group if [[ "${#groups[@]}" -eq 0 ]]; then groups=(all) fi for group in "${groups[@]}"; do install_requested_group "${group}" done } main "$@"