diff --git a/misc/alpine-tools.func b/misc/alpine-tools.func new file mode 100644 index 00000000..b03f4f08 --- /dev/null +++ b/misc/alpine-tools.func @@ -0,0 +1,535 @@ +#!/bin/ash +# shellcheck shell=ash + +# Erwartet vorhandene msg_* und optional $STD aus deinem Framework. + +# ------------------------------ +# kleine Helfer +# ------------------------------ +lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } +has() { command -v "$1" >/dev/null 2>&1; } + +need_tool() { + # usage: need_tool curl jq unzip ... + # installiert fehlende Tools via apk --no-cache + local missing=0 t + for t in "$@"; do + if ! has "$t"; then missing=1; fi + done + if [ "$missing" -eq 1 ]; then + msg_info "Installing tools: $*" + # busybox 'apk' ist vorhanden auf Alpine + apk add --no-cache "$@" >/dev/null 2>&1 || { + msg_error "apk add failed for: $*" + return 1 + } + msg_ok "Tools ready: $*" + fi +} + +net_resolves() { + # robust gegen fehlendes getent auf busybox + # usage: net_resolves api.github.com + local host="$1" + ping -c1 -W1 "$host" >/dev/null 2>&1 || nslookup "$host" >/dev/null 2>&1 +} + +ensure_usr_local_bin_persist() { + local PROFILE_FILE="/etc/profile.d/10-localbin.sh" + if [ ! -f "$PROFILE_FILE" ]; then + echo 'case ":$PATH:" in *:/usr/local/bin:*) ;; *) export PATH="/usr/local/bin:$PATH";; esac' >"$PROFILE_FILE" + chmod +x "$PROFILE_FILE" + fi +} + +download_with_progress() { + # $1 url, $2 dest + local url="$1" out="$2" cl + need_tool curl pv || return 1 + cl=$(curl -fsSLI "$url" 2>/dev/null | awk 'tolower($0) ~ /^content-length:/ {print $2}' | tr -d '\r') + if [ -n "$cl" ]; then + curl -fsSL "$url" | pv -s "$cl" >"$out" || { + msg_error "Download failed: $url" + return 1 + } + else + curl -fL# -o "$out" "$url" || { + msg_error "Download failed: $url" + return 1 + } + fi +} + +# ------------------------------ +# GitHub: Release prüfen +# ------------------------------ +check_for_gh_release() { + # app, repo, [pinned] + local app="$1" source="$2" pinned="${3:-}" + local app_lc + app_lc="$(lower "$app" | tr -d ' ')" + local current_file="$HOME/.${app_lc}" + local current="" release tag + + msg_info "Check for update: $app" + + net_resolves api.github.com || { + msg_error "DNS/network error: api.github.com" + return 1 + } + need_tool curl jq || return 1 + + tag=$(curl -fsSL "https://api.github.com/repos/${source}/releases/latest" | jq -r '.tag_name // empty') + [ -z "$tag" ] && { + msg_error "Unable to fetch latest tag for $app" + return 1 + } + release="${tag#v}" + + [ -f "$current_file" ] && current="$(cat "$current_file")" + + if [ -n "$pinned" ]; then + if [ "$pinned" = "$release" ]; then + msg_ok "$app pinned to v$pinned (no update)" + return 1 + fi + if [ "$current" = "$pinned" ]; then + msg_ok "$app pinned v$pinned installed (upstream v$release)" + return 1 + fi + msg_info "$app pinned v$pinned (upstream v$release) → update/downgrade" + CHECK_UPDATE_RELEASE="$pinned" + return 0 + fi + + if [ "$release" != "$current" ] || [ ! -f "$current_file" ]; then + CHECK_UPDATE_RELEASE="$release" + msg_info "New release available: v$release (current: v${current:-none})" + return 0 + fi + + msg_ok "$app is up to date (v$release)" + return 1 +} + +# ------------------------------ +# GitHub: Release holen & deployen (Alpine) +# modes: tarball | prebuild | singlefile +# ------------------------------ +fetch_and_deploy_gh() { + # $1 app, $2 repo, [$3 mode], [$4 version], [$5 target], [$6 asset_pattern] + local app="$1" repo="$2" mode="${3:-tarball}" version="${4:-latest}" target="${5:-/opt/$1}" pattern="${6:-}" + local app_lc + app_lc="$(lower "$app" | tr -d ' ')" + local vfile="$HOME/.${app_lc}" + local json url filename tmpd unpack + + net_resolves api.github.com || { + msg_error "DNS/network error" + return 1 + } + need_tool curl jq tar || return 1 + [ "$mode" = "prebuild" ] || [ "$mode" = "singlefile" ] && need_tool unzip >/dev/null 2>&1 || true + + tmpd="$(mktemp -d)" || return 1 + mkdir -p "$target" + + # Release JSON + if [ "$version" = "latest" ]; then + json="$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest")" || { + msg_error "GitHub API failed" + rm -rf "$tmpd" + return 1 + } + else + json="$(curl -fsSL "https://api.github.com/repos/$repo/releases/tags/$version")" || { + msg_error "GitHub API failed" + rm -rf "$tmpd" + return 1 + } + fi + + # Effektive Version + version="$(printf '%s' "$json" | jq -r '.tag_name // empty')" + version="${version#v}" + + [ -z "$version" ] && { + msg_error "No tag in release json" + rm -rf "$tmpd" + return 1 + } + + case "$mode" in + tarball | source) + url="$(printf '%s' "$json" | jq -r '.tarball_url // empty')" + [ -z "$url" ] && url="https://github.com/$repo/archive/refs/tags/v$version.tar.gz" + filename="${app_lc}-${version}.tar.gz" + download_with_progress "$url" "$tmpd/$filename" || { + rm -rf "$tmpd" + return 1 + } + tar -xzf "$tmpd/$filename" -C "$tmpd" || { + msg_error "tar extract failed" + rm -rf "$tmpd" + return 1 + } + unpack="$(find "$tmpd" -mindepth 1 -maxdepth 1 -type d | head -n1)" + # Inhalte nach target kopieren (inkl. dotfiles) + (cd "$unpack" && tar -cf - .) | (cd "$target" && tar -xf -) || { + msg_error "copy failed" + rm -rf "$tmpd" + return 1 + } + ;; + prebuild) + [ -n "$pattern" ] || { + msg_error "prebuild requires asset pattern" + rm -rf "$tmpd" + return 1 + } + url="$(printf '%s' "$json" | jq -r '.assets[].browser_download_url' | awk -v p="$pattern" ' + BEGIN{IGNORECASE=1} + $0 ~ p {print; exit} + ')" + [ -z "$url" ] && { + msg_error "asset not found for pattern: $pattern" + rm -rf "$tmpd" + return 1 + } + filename="${url##*/}" + download_with_progress "$url" "$tmpd/$filename" || { + rm -rf "$tmpd" + return 1 + } + # entpacken je nach Format + case "$filename" in + *.zip) + need_tool unzip || { + rm -rf "$tmpd" + return 1 + } + mkdir -p "$tmpd/unp" + unzip -q "$tmpd/$filename" -d "$tmpd/unp" + ;; + *.tar.gz | *.tgz | *.tar.xz | *.tar.zst | *.tar.bz2) + mkdir -p "$tmpd/unp" + tar -xf "$tmpd/$filename" -C "$tmpd/unp" + ;; + *) + msg_error "unsupported archive: $filename" + rm -rf "$tmpd" + return 1 + ;; + esac + # top-level folder ggf. strippen + if [ "$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq 1 ] && [ -z "$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type f | head -n1)" ]; then + unpack="$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type d)" + (cd "$unpack" && tar -cf - .) | (cd "$target" && tar -xf -) || { + msg_error "copy failed" + rm -rf "$tmpd" + return 1 + } + else + (cd "$tmpd/unp" && tar -cf - .) | (cd "$target" && tar -xf -) || { + msg_error "copy failed" + rm -rf "$tmpd" + return 1 + } + fi + ;; + singlefile) + [ -n "$pattern" ] || { + msg_error "singlefile requires asset pattern" + rm -rf "$tmpd" + return 1 + } + url="$(printf '%s' "$json" | jq -r '.assets[].browser_download_url' | awk -v p="$pattern" ' + BEGIN{IGNORECASE=1} + $0 ~ p {print; exit} + ')" + [ -z "$url" ] && { + msg_error "asset not found for pattern: $pattern" + rm -rf "$tmpd" + return 1 + } + filename="${url##*/}" + download_with_progress "$url" "$target/$app" || { + rm -rf "$tmpd" + return 1 + } + chmod +x "$target/$app" + ;; + *) + msg_error "Unknown mode: $mode" + rm -rf "$tmpd" + return 1 + ;; + esac + + echo "$version" >"$vfile" + ensure_usr_local_bin_persist + rm -rf "$tmpd" + msg_ok "Deployed $app ($version) → $target" +} + +# ------------------------------ +# yq (mikefarah) – Alpine +# ------------------------------ +setup_yq() { + # bevorzugt apk, optional FORCE_GH=1 → GitHub Binary + if [ "${FORCE_GH:-0}" != "1" ] && apk info -e yq >/dev/null 2>&1; then + msg_info "Updating yq via apk" + apk add --no-cache --upgrade yq >/dev/null 2>&1 || true + msg_ok "yq ready ($(yq --version 2>/dev/null))" + return 0 + fi + + need_tool curl || return 1 + local arch bin url tmp + case "$(uname -m)" in + x86_64) arch="amd64" ;; + aarch64) arch="arm64" ;; + *) + msg_error "Unsupported arch for yq: $(uname -m)" + return 1 + ;; + esac + url="https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${arch}" + tmp="$(mktemp)" + download_with_progress "$url" "$tmp" || return 1 + install -m 0755 "$tmp" /usr/local/bin/yq + rm -f "$tmp" + msg_ok "Setup yq ($(yq --version 2>/dev/null))" +} + +# ------------------------------ +# Adminer – Alpine +# ------------------------------ +setup_adminer() { + need_tool curl || return 1 + msg_info "Setup Adminer (Alpine)" + mkdir -p /var/www/localhost/htdocs/adminer + curl -fsSL https://github.com/vrana/adminer/releases/latest/download/adminer.php \ + -o /var/www/localhost/htdocs/adminer/index.php || { + msg_error "Adminer download failed" + return 1 + } + msg_ok "Adminer at /adminer (served by your webserver)" +} + +# ------------------------------ +# uv – Alpine (musl tarball) +# Optional: PYTHON_VERSION="3.12" +# ------------------------------ +setup_uv() { + need_tool curl tar || return 1 + local UV_BIN="/usr/local/bin/uv" + local arch tarball url tmpd ver installed + + case "$(uname -m)" in + x86_64) arch="x86_64-unknown-linux-musl" ;; + aarch64) arch="aarch64-unknown-linux-musl" ;; + *) + msg_error "Unsupported arch for uv: $(uname -m)" + return 1 + ;; + esac + + ver="$(curl -fsSL https://api.github.com/repos/astral-sh/uv/releases/latest | jq -r '.tag_name' 2>/dev/null)" + ver="${ver#v}" + [ -z "$ver" ] && { + msg_error "uv: cannot determine latest version" + return 1 + } + + if has "$UV_BIN"; then + installed="$($UV_BIN -V 2>/dev/null | awk '{print $2}')" + [ "$installed" = "$ver" ] && { + msg_ok "uv $ver already installed" + return 0 + } + msg_info "Updating uv $installed → $ver" + else + msg_info "Setup uv $ver" + fi + + tmpd="$(mktemp -d)" || return 1 + tarball="uv-${arch}.tar.gz" + url="https://github.com/astral-sh/uv/releases/download/v${ver}/${tarball}" + + download_with_progress "$url" "$tmpd/uv.tar.gz" || { + rm -rf "$tmpd" + return 1 + } + tar -xzf "$tmpd/uv.tar.gz" -C "$tmpd" || { + msg_error "uv: extract failed" + rm -rf "$tmpd" + return 1 + } + + # tar enthält ./uv + if [ -x "$tmpd/uv" ]; then + install -m 0755 "$tmpd/uv" "$UV_BIN" + else + # fallback: in Unterordner + install -m 0755 "$tmpd"/*/uv "$UV_BIN" 2>/dev/null || { + msg_error "uv binary not found in tar" + rm -rf "$tmpd" + return 1 + } + fi + rm -rf "$tmpd" + ensure_usr_local_bin_persist + msg_ok "Setup uv $ver" + + if [ -n "${PYTHON_VERSION:-}" ]; then + # uv liefert cpython builds für musl; den neuesten Patchstand finden: + local match + match="$(uv python list --only-downloads 2>/dev/null | awk -v maj="$PYTHON_VERSION" ' + $0 ~ "^cpython-"maj"\\." { print $0 }' | awk -F- '{print $2}' | sort -V | tail -n1)" + [ -z "$match" ] && { + msg_error "No matching Python for $PYTHON_VERSION" + return 1 + } + if ! uv python list | grep -q "cpython-${match}-linux"; then + msg_info "Installing Python $match via uv" + uv python install "$match" || { + msg_error "uv python install failed" + return 1 + } + msg_ok "Python $match installed (uv)" + fi + fi +} + +# ------------------------------ +# Java – Alpine (OpenJDK) +# JAVA_VERSION: 17|21 (Default 21) +# ------------------------------ +setup_java() { + local JAVA_VERSION="${JAVA_VERSION:-21}" pkg + case "$JAVA_VERSION" in + 17) pkg="openjdk17-jdk" ;; + 21 | *) pkg="openjdk21-jdk" ;; + esac + msg_info "Setup Java (OpenJDK $JAVA_VERSION)" + apk add --no-cache "$pkg" >/dev/null 2>&1 || { + msg_error "apk add $pkg failed" + return 1 + } + # JAVA_HOME setzen + local prof="/etc/profile.d/20-java.sh" + if [ ! -f "$prof" ]; then + echo 'export JAVA_HOME=$(dirname $(dirname $(readlink -f $(command -v java))))' >"$prof" + echo 'case ":$PATH:" in *:$JAVA_HOME/bin:*) ;; *) export PATH="$JAVA_HOME/bin:$PATH";; esac' >>"$prof" + chmod +x "$prof" + fi + msg_ok "Java ready: $(java -version 2>&1 | head -n1)" +} + +# ------------------------------ +# Go – Alpine (apk bevorzugt; optional GO_VERSION tarball) +# ------------------------------ +setup_go() { + if [ -z "${GO_VERSION:-}" ]; then + msg_info "Setup Go (apk)" + apk add --no-cache go >/dev/null 2>&1 || { + msg_error "apk add go failed" + return 1 + } + msg_ok "Go ready: $(go version 2>/dev/null)" + return 0 + fi + + # explizite Version via offizielles tar.gz + need_tool curl tar || return 1 + local ARCH TARBALL URL TMP + case "$(uname -m)" in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64" ;; + *) + msg_error "Unsupported arch for Go: $(uname -m)" + return 1 + ;; + esac + TARBALL="go${GO_VERSION}.linux-${ARCH}.tar.gz" + URL="https://go.dev/dl/${TARBALL}" + msg_info "Setup Go $GO_VERSION (tarball)" + TMP="$(mktemp)" + download_with_progress "$URL" "$TMP" || return 1 + rm -rf /usr/local/go + tar -C /usr/local -xzf "$TMP" || { + msg_error "extract go failed" + rm -f "$TMP" + return 1 + } + rm -f "$TMP" + ln -sf /usr/local/go/bin/go /usr/local/bin/go + ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt + ensure_usr_local_bin_persist + msg_ok "Go ready: $(go version 2>/dev/null)" +} + +# ------------------------------ +# Composer – Alpine +# nutzt php83-cli + openssl + phar +# ------------------------------ +setup_composer() { + local COMPOSER_BIN="/usr/local/bin/composer" + if ! has php; then + # bevorzugt php83 auf Alpine 3.20/3.21+ + msg_info "Installing PHP CLI for Composer" + apk add --no-cache php83-cli php83-openssl php83-phar php83-iconv >/dev/null 2>&1 || { + # Fallback auf generisches php-cli + apk add --no-cache php-cli php-openssl php-phar php-iconv >/dev/null 2>&1 || { + msg_error "Failed to install php-cli for composer" + return 1 + } + } + fi + + if [ -x "$COMPOSER_BIN" ]; then + msg_info "Updating Composer" + else + msg_info "Setup Composer" + fi + + need_tool curl || return 1 + curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php || { + msg_error "composer installer download failed" + return 1 + } + php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer >/dev/null 2>&1 || { + msg_error "composer install failed" + return 1 + } + rm -f /tmp/composer-setup.php + ensure_usr_local_bin_persist + msg_ok "Composer ready: $(composer --version 2>/dev/null)" +} + +# ------------------------------ +# Adminer/uv/go/java/yq/composer stehen oben +# ------------------------------ + +# ------------------------------ +# (Optional) LOCAL_IP import – POSIX-safe +# ------------------------------ +import_local_ip() { + # lädt LOCAL_IP aus /run/local-ip.env oder ermittelt es best effort + local IP_FILE="/run/local-ip.env" + if [ -f "$IP_FILE" ]; then + # shellcheck disable=SC1090 + . "$IP_FILE" + fi + if [ -z "${LOCAL_IP:-}" ]; then + LOCAL_IP="$(ip route get 1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')" + [ -z "$LOCAL_IP" ] && LOCAL_IP="$(hostname -i 2>/dev/null | awk '{print $1}')" + [ -z "$LOCAL_IP" ] && { + msg_error "Could not determine LOCAL_IP" + return 1 + } + echo "LOCAL_IP=$LOCAL_IP" >"$IP_FILE" + fi + export LOCAL_IP +}