#!/bin/sh # USM system-package-manager helper for apk-based systems (Alpine). # # Implements the USM SPM contract (see usm/README.md, "System package # manager integration") for Alpine-style systems: # # usm-spm-apk query ... -> contract JSON on STDOUT # usm-spm-apk install ... -> contract JSONL events on STDOUT # # API choice: pure POSIX shell (busybox ash) on purpose -- the Alpine # base image ships neither python3 nor jq, and the SPM bootstrap must # stay at zero extra packages (busybox is already there). apk-tools # >= 2.10 (alpine:latest currently ships apk-tools 3.0.6) expose # provides namespaces that map USM refs natively, so file-list indexes # are not needed for the hot types: # # bin:/sbin: X -> provides "cmd:X" (abuild auto-generates for # every packaged executable, /usr/bin and /sbin) # lib: X -> provides "so:X" (verbatim soname) # pc: X -> provides "pc:${X%.pc}" -- apk indexes pkg-config # module names WITHOUT the ".pc" suffix (observed: # glib-dev provides pc:glib-2.0, vala provides # pc:libvala-0.56), so one trailing ".pc" is # stripped from the ref before querying # # Providers are found with `apk search -x ` (exact match that # also matches provides); the from-scratch solo-install closure comes # from `apk search --recursive ` (runs the solver against the # repositories only -- same semantics as the dnf helper's repo-only # sack); installed-dependency-count is the overlap of that closure with # the installed database (`apk info`). Residual resource types (inc:, # vapi:, gir:, typelib:, res:, cfg:, man:, info:, locale:, libexec:, # rootpath:, tag:) are resolved against the installed database only via # `apk info -W`/directory sampling: APKINDEX carries no file lists, so # files of not-yet-installed packages are unresolvable and land in # "not-found" (documented limitation, the same offline stance as the # emerge helper). # # apk has no per-download progress output on a non-tty stdout; it # prints one "(k/N) Installing pkg (ver)" line per package instead, so # "package" events are emitted at package-completion granularity # (progress 1.0) driven by those lines, and begin.total comes from an # `apk add --simulate` pass against the current system. # # Install transactions expand INSTALL_COMPANIONS (currently gcc -> # musl-dev): apk's gcc package deliberately ships no crt objects, and a # USM manifest cannot express them as resource refs. # # The query subcommand never modifies system state: repository indexes # are fetched into a throwaway cache directory (at most refreshing # package-metadata caches). The install subcommand must run as root; # apk never prompts interactively: unresolved dependencies or bad # signatures fail instead. # # Alpine path table (musl/busybox layout, no lib64): /usr/bin, /sbin, # /lib, /usr/lib, /usr/include, /usr/share (+/usr/share/pkgconfig). set -eu export LC_ALL=C PROG=usm-spm-apk EXIT_OK=0 EXIT_FAILURE=1 EXIT_USAGE=2 EXIT_RESOLVE=3 EXIT_DOWNLOAD=4 EXIT_TRANSACTION=5 WORK="" cleanup() { if [ -n "$WORK" ]; then rm -rf "$WORK" fi } warn() { printf '%s: %s\n' "$PROG" "$*" >&2 } die_usage() { printf 'usage: %s query ...\n' "$PROG" >&2 printf ' %s install ...\n' "$PROG" >&2 exit "$EXIT_USAGE" } emit() { printf '%s\n' "$1" } json_escape() { printf '%s' "$1" | awk ' { s = $0 gsub(/\\/, "\\\\", s) gsub(/"/, "\\\"", s) gsub(/\t/, "\\t", s) gsub(/[[:cntrl:]]/, "", s) print s }' } mkwork() { WORK=$(mktemp -d "${TMPDIR:-/tmp}/usm-spm-apk.XXXXXX" 2>/dev/null) || { WORK="${TMPDIR:-/tmp}/usm-spm-apk.$$" (umask 077 && mkdir "$WORK") } trap cleanup EXIT } prime_index_cache() { CACHE="$WORK/cache" PRIMED=0 if mkdir -p "$CACHE" && apk update -q --cache-dir "$CACHE" >/dev/null 2>"$WORK/prime.err"; then if [ -n "$(ls -A "$CACHE" 2>/dev/null)" ]; then PRIMED=1 fi fi if [ "$PRIMED" != 1 ]; then warn "could not prime a repository index cache; every lookup will fetch indexes" fi } sp_search() { if [ "$PRIMED" = 1 ]; then apk search --cache-dir "$CACHE" "$@" else apk search --no-cache "$@" fi } apk_diag_line() { [ -r "$1" ] || return 0 sed -n 's/^\(ERROR\|WARNING\):[[:space:]]*//p' "$1" | head -n 1 } strip_version() { sed -e 's/-r[0-9][0-9]*$//' -e 's/-[0-9][0-9A-Za-z._+]*$//' } not_found_add() { printf '%s\n' "$1" >>"$WORK/nf" } map_add() { printf '%s\t%s\n' "$1" "$2" >>"$WORK/map" } record_providers() { _rp_ref=$1 _rp_prov=$2 _rp_out=$(sp_search -x -- "$_rp_prov" 2>"$WORK/search.err" || true) if { [ -z "$_rp_out" ] && [ -s "$WORK/search.err" ]; } || grep -q '^ERROR' "$WORK/search.err" 2>/dev/null; then warn "apk search failed for \"$_rp_prov\": $(apk_diag_line "$WORK/search.err")" exit "$EXIT_RESOLVE" fi if [ -n "$_rp_out" ]; then printf '%s\n' "$_rp_out" | strip_version | sort -u >"$WORK/pv" while IFS= read -r _rp_nm; do [ -n "$_rp_nm" ] || continue map_add "$_rp_nm" "$_rp_ref" done <"$WORK/pv" else not_found_add "$_rp_ref" fi } record_file_owner() { _fo_ref=$1 shift _fo_hit=0 for _fo_p in "$@"; do _fo_f= if [ -d "$_fo_p" ]; then _fo_f=$(find "$_fo_p" -type f 2>/dev/null | head -n 1) || _fo_f= elif [ -f "$_fo_p" ]; then _fo_f=$_fo_p else continue fi [ -n "$_fo_f" ] || continue _fo_own=$(apk info -W "$_fo_f" 2>/dev/null) || continue _fo_nm=$(printf '%s\n' "$_fo_own" | sed -n 's/^.* is owned by //p' | strip_version) [ -n "$_fo_nm" ] || continue map_add "$_fo_nm" "$_fo_ref" _fo_hit=1 done if [ "$_fo_hit" != 1 ]; then not_found_add "$_fo_ref" fi } handle_ref() { _hr_ref=$1 _hr_type=${_hr_ref%%:*} _hr_res=${_hr_ref#*:} if [ "$_hr_type" = "$_hr_ref" ] || [ -z "$_hr_res" ]; then warn "resource type of \"$_hr_ref\" has no system-package-manager translation" not_found_add "$_hr_ref" return fi case $_hr_res in *[!A-Za-z0-9._+/-]*) warn "resource name of \"$_hr_ref\" has no system-package-manager translation" not_found_add "$_hr_ref" return ;; esac case $_hr_type in bin|sbin) record_providers "$_hr_ref" "cmd:$_hr_res" ;; lib) record_providers "$_hr_ref" "so:$_hr_res" ;; pc) record_providers "$_hr_ref" "pc:${_hr_res%.pc}" ;; libexec) record_file_owner "$_hr_ref" "/usr/libexec/$_hr_res" ;; gir) record_file_owner "$_hr_ref" "/usr/share/gir-1.0/$_hr_res" ;; typelib) record_file_owner "$_hr_ref" "/usr/lib/girepository-1.0/$_hr_res" ;; res) record_file_owner "$_hr_ref" "/usr/share/$_hr_res" ;; cfg) record_file_owner "$_hr_ref" "/etc/$_hr_res" ;; man) record_file_owner "$_hr_ref" "/usr/share/man/$_hr_res" ;; info) record_file_owner "$_hr_ref" "/usr/share/info/$_hr_res" ;; locale) record_file_owner "$_hr_ref" "/usr/share/locale/$_hr_res" ;; inc) record_file_owner "$_hr_ref" "/usr/include/$_hr_res" ;; vapi) set -- "/usr/share/vala/vapi/$_hr_res" for _hr_d in /usr/share/vala-*/vapi/"$_hr_res"; do if [ -f "$_hr_d" ]; then set -- "$@" "$_hr_d" fi done record_file_owner "$_hr_ref" "$@" ;; rootpath) record_file_owner "$_hr_ref" "/$_hr_res" ;; tag) _hr_t=${_hr_res%.tag} record_file_owner "$_hr_ref" "/usr/share/usm-tags/$(printf '%s' "$_hr_t" | tr '.' '/').tag" ;; *) warn "resource type of \"$_hr_ref\" has no system-package-manager translation" not_found_add "$_hr_ref" return ;; esac } counts_for() { _cf_name=$1 _cf_cached= if [ -s "$WORK/counts" ]; then _cf_cached=$(awk -F'\t' -v n="$_cf_name" '$1 == n { print $2 " " $3; exit }' "$WORK/counts") || _cf_cached= fi if [ -n "$_cf_cached" ]; then printf '%s\n' "$_cf_cached" return fi _cf_cl=$(sp_search --recursive -- "$_cf_name" 2>"$WORK/rec.err" || true) if [ -z "$_cf_cl" ] || grep -q '^ERROR' "$WORK/rec.err" 2>/dev/null; then warn "could not resolve solo install of $_cf_name, estimating dependency counts" if grep -qx -F "$_cf_name" "$WORK/installed" 2>/dev/null; then printf '1 1\n' else printf '1 0\n' fi return fi printf '%s\n' "$_cf_cl" | strip_version >"$WORK/cl" _cf_dep=$(awk 'END { print NR }' "$WORK/cl") _cf_inst=0 if [ -s "$WORK/installed" ]; then _cf_inst=$(grep -x -F -f "$WORK/installed" "$WORK/cl" | awk 'END { print NR }') || _cf_inst=0 fi printf '%s\t%s\t%s\n' "$_cf_name" "$_cf_dep" "$_cf_inst" >>"$WORK/counts" printf '%s %s\n' "$_cf_dep" "$_cf_inst" } cmd_query() { [ $# -ge 1 ] || die_usage mkwork prime_index_cache apk info >"$WORK/installed" 2>/dev/null || : >"$WORK/installed" : >"$WORK/map" : >"$WORK/nf" : >"$WORK/counts" : >"$WORK/seen" for _q_ref in "$@"; do if [ -n "$_q_ref" ] && grep -x -F -q "$_q_ref" "$WORK/seen" 2>/dev/null; then continue fi printf '%s\n' "$_q_ref" >>"$WORK/seen" handle_ref "$_q_ref" done _q_out='{"not-found":[' _q_first=1 while IFS= read -r _q_r; do [ -n "$_q_r" ] || continue if [ "$_q_first" = 1 ]; then _q_first=0; else _q_out="$_q_out,"; fi _q_out="$_q_out\"$(json_escape "$_q_r")\"" done <"$WORK/nf" _q_out="$_q_out],\"packages\":[" _q_first=1 for _q_nm in $(awk -F'\t' '{ print $1 }' "$WORK/map" | sort -u); do if [ "$_q_first" = 1 ]; then _q_first=0; else _q_out="$_q_out,"; fi awk -F'\t' -v n="$_q_nm" '$1 == n { print $2 }' "$WORK/map" >"$WORK/tmpres" _q_res="" _q_rf=1 while IFS= read -r _q_rr; do if [ "$_q_rf" = 1 ]; then _q_rf=0; else _q_res="$_q_res,"; fi _q_res="$_q_res\"$(json_escape "$_q_rr")\"" done <"$WORK/tmpres" _q_cnts=$(counts_for "$_q_nm") _q_dep=${_q_cnts%% *} _q_inst=${_q_cnts##* } _q_out="$_q_out{\"name\":\"$(json_escape "$_q_nm")\",\"resources\":[${_q_res}],\"dependency-count\":$_q_dep,\"installed-dependency-count\":$_q_inst}" done _q_out="$_q_out]}" emit "$_q_out" exit "$EXIT_OK" } classify_failure() { if grep -Eqi 'fetch|download|network|temporary failure|connection refused|timed out|untrusted|checksum|signature|mirror|404|503' "$1" 2>/dev/null; then printf '%s\n' "$EXIT_DOWNLOAD" elif grep -Eq 'unable to select|constraint|conflict|world' "$1" 2>/dev/null; then printf '%s\n' "$EXIT_RESOLVE" else printf '%s\n' "$EXIT_TRANSACTION" fi } # Packages whose apk split leaves a toolchain unusable alone: gcc ships no # crt objects (musl-dev owns Scrt1.o/crti.o/libssp_nonshared.a), so linking # fails without it. USM manifests cannot express crt files as resource # refs; this table completes the toolchain at install time instead. INSTALL_COMPANIONS="gcc:musl-dev" cmd_install() { [ $# -ge 1 ] || die_usage for _i_n in "$@"; do case $_i_n in -*) die_usage ;; esac done _i_expanded="" for _i_n in "$@"; do _i_expanded="$_i_expanded $_i_n" for _i_c in $INSTALL_COMPANIONS; do if [ "${_i_c%%:*}" = "$_i_n" ]; then _i_expanded="$_i_expanded ${_i_c#*:}" fi done done set -- $_i_expanded mkwork _i_simrc=0 _i_sim=$(apk add --simulate --no-cache --no-progress -- "$@" 2>"$WORK/sim.err") || _i_simrc=$? if [ "$_i_simrc" != 0 ]; then _i_msg=$(apk_diag_line "$WORK/sim.err") [ -n "$_i_msg" ] || _i_msg="apk add --simulate failed with status $_i_simrc" emit "{\"type\":\"error\",\"message\":\"$(json_escape "$_i_msg")\"}" exit "$(classify_failure "$WORK/sim.err")" fi _i_total=$(printf '%s\n' "$_i_sim" | sed -n 's/^(\([0-9][0-9]*\)\/\([0-9][0-9]*\)).*/\2/p' | tail -n 1) [ -n "$_i_total" ] || _i_total=0 emit "{\"type\":\"begin\",\"total\":$_i_total}" { apk add --no-cache --no-progress -- "$@" 2>"$WORK/inst.err" printf '%s\n' "$?" >"$WORK/rc" } | { _i_done=0 while IFS= read -r _i_line || [ -n "$_i_line" ]; do case $_i_line in \(*\)) _i_kn=${_i_line#"("} _i_kn=${_i_kn%%")"*} _i_k=${_i_kn%%/*} _i_n=${_i_kn##*/} _i_rest=${_i_line#*") "} _i_verb=${_i_rest%% *} _i_pkg=${_i_rest#* } _i_pkg=${_i_pkg%% *} case $_i_verb in Installing|Upgrading|Reinstalling|Downgrading) emit "{\"type\":\"package\",\"name\":\"$(json_escape "$_i_pkg")\",\"current\":$_i_k,\"total\":$_i_n,\"progress\":1.0}" emit "{\"type\":\"package-complete\",\"name\":\"$(json_escape "$_i_pkg")\"}" _i_done=$((_i_done + 1)) ;; esac ;; esac done printf '%s\n' "$_i_done" >"$WORK/done" } _i_rc=$(cat "$WORK/rc" 2>/dev/null) || _i_rc=1 if [ "$_i_rc" = 0 ]; then _i_done=$(cat "$WORK/done" 2>/dev/null) || _i_done=0 emit "{\"type\":\"complete\",\"status\":\"ok\",\"installed\":$_i_done}" exit "$EXIT_OK" fi _i_msg=$(apk_diag_line "$WORK/inst.err") [ -n "$_i_msg" ] || _i_msg="apk add failed with status $_i_rc" emit "{\"type\":\"error\",\"message\":\"$(json_escape "$_i_msg")\"}" exit "$(classify_failure "$WORK/inst.err")" } main() { if [ $# -lt 1 ]; then die_usage fi if ! command -v apk >/dev/null 2>&1; then if [ "$1" = install ]; then emit '{"type":"error","message":"apk not found in PATH"}' exit "$EXIT_FAILURE" fi warn "apk not found in PATH" exit "$EXIT_RESOLVE" fi _m_cmd=$1 shift case $_m_cmd in query) cmd_query "$@" ;; install) cmd_install "$@" ;; -h|--help|help) printf 'usage: %s query ...\n' "$PROG" printf ' %s install ...\n' "$PROG" exit "$EXIT_OK" ;; *) die_usage ;; esac } main "$@"