Files
computer-playbook/scripts/install/lint.sh
T

729 lines
15 KiB
Bash
Executable File

#!/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 "$@"