From d417986fad87ce0a4baf5c6e48ad6b94fa7ca723 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 14:30:47 +0300 Subject: [PATCH 1/2] fix(recipes): probe the escalation tool before python-dev uses it Alpine ships a sudoers file that grants root nothing. sudo is on PATH and refuses every command, so as_user picked it, the runcmd failed, and cloud-init ended in error. The readiness wait then failed stoat up. as_user now runs each candidate once and keeps the first that works. The su fake in the test harness matched -c against its -* case, so the fallback branch could never pass. The fake now parses -c first and forwards the remaining arguments. Signed-off-by: NovusEdge --- .../bundled/python-dev/install-alpine.sh | 23 ++++++++++---- .../bundled/python-dev/install-arch.sh | 23 ++++++++++---- .../bundled/python-dev/install-debian.sh | 23 ++++++++++---- .../recipes/bundled/python-dev/install-rpm.sh | 23 ++++++++++---- internal/recipes/samples_test.go | 30 +++++++++++++++++-- 5 files changed, 96 insertions(+), 26 deletions(-) diff --git a/internal/recipes/bundled/python-dev/install-alpine.sh b/internal/recipes/bundled/python-dev/install-alpine.sh index 34d4d79d..f783d5ca 100755 --- a/internal/recipes/bundled/python-dev/install-alpine.sh +++ b/internal/recipes/bundled/python-dev/install-alpine.sh @@ -12,16 +12,27 @@ fi stoat_pkg_setup stoat_pkg_install python3 py3-pip +# Alpine's sudoers file grants root nothing, so sudo is on PATH and refuses +# every command. Probe each tool once and keep the first that runs. as_user() { - if command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- "$@" - elif command -v sudo >/dev/null 2>&1; then - sudo -n -u "$user" -- "$@" - else + if [ -z "${as_user_tool:-}" ]; then + if command -v runuser >/dev/null 2>&1 && runuser -u "$user" -- true 2>/dev/null; then + as_user_tool=runuser + elif command -v sudo >/dev/null 2>&1 && sudo -n -u "$user" -- true 2>/dev/null; then + as_user_tool=sudo + else + as_user_tool=su + fi + fi + case "$as_user_tool" in + runuser) runuser -u "$user" -- "$@" ;; + sudo) sudo -n -u "$user" -- "$@" ;; + *) command=$1 shift su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" - fi + ;; + esac } python_bin=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true) diff --git a/internal/recipes/bundled/python-dev/install-arch.sh b/internal/recipes/bundled/python-dev/install-arch.sh index 08707c0d..fd4739c5 100755 --- a/internal/recipes/bundled/python-dev/install-arch.sh +++ b/internal/recipes/bundled/python-dev/install-arch.sh @@ -12,16 +12,27 @@ fi stoat_pkg_setup stoat_pkg_install python python-pip +# A sudo binary on PATH is not a working sudo: Alpine's sudoers grants root +# nothing. Probe each tool once and keep the first that runs. as_user() { - if command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- "$@" - elif command -v sudo >/dev/null 2>&1; then - sudo -n -u "$user" -- "$@" - else + if [ -z "${as_user_tool:-}" ]; then + if command -v runuser >/dev/null 2>&1 && runuser -u "$user" -- true 2>/dev/null; then + as_user_tool=runuser + elif command -v sudo >/dev/null 2>&1 && sudo -n -u "$user" -- true 2>/dev/null; then + as_user_tool=sudo + else + as_user_tool=su + fi + fi + case "$as_user_tool" in + runuser) runuser -u "$user" -- "$@" ;; + sudo) sudo -n -u "$user" -- "$@" ;; + *) command=$1 shift su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" - fi + ;; + esac } python_bin=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true) diff --git a/internal/recipes/bundled/python-dev/install-debian.sh b/internal/recipes/bundled/python-dev/install-debian.sh index 3e1b30aa..9786ee26 100755 --- a/internal/recipes/bundled/python-dev/install-debian.sh +++ b/internal/recipes/bundled/python-dev/install-debian.sh @@ -12,16 +12,27 @@ fi stoat_pkg_setup stoat_pkg_install python3 python3-pip python3-venv +# A sudo binary on PATH is not a working sudo: Alpine's sudoers grants root +# nothing. Probe each tool once and keep the first that runs. as_user() { - if command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- "$@" - elif command -v sudo >/dev/null 2>&1; then - sudo -n -u "$user" -- "$@" - else + if [ -z "${as_user_tool:-}" ]; then + if command -v runuser >/dev/null 2>&1 && runuser -u "$user" -- true 2>/dev/null; then + as_user_tool=runuser + elif command -v sudo >/dev/null 2>&1 && sudo -n -u "$user" -- true 2>/dev/null; then + as_user_tool=sudo + else + as_user_tool=su + fi + fi + case "$as_user_tool" in + runuser) runuser -u "$user" -- "$@" ;; + sudo) sudo -n -u "$user" -- "$@" ;; + *) command=$1 shift su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" - fi + ;; + esac } python_bin=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true) diff --git a/internal/recipes/bundled/python-dev/install-rpm.sh b/internal/recipes/bundled/python-dev/install-rpm.sh index 679b2083..c6c82db1 100755 --- a/internal/recipes/bundled/python-dev/install-rpm.sh +++ b/internal/recipes/bundled/python-dev/install-rpm.sh @@ -12,16 +12,27 @@ fi stoat_pkg_setup stoat_pkg_install python3 python3-pip +# A sudo binary on PATH is not a working sudo: Alpine's sudoers grants root +# nothing. Probe each tool once and keep the first that runs. as_user() { - if command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- "$@" - elif command -v sudo >/dev/null 2>&1; then - sudo -n -u "$user" -- "$@" - else + if [ -z "${as_user_tool:-}" ]; then + if command -v runuser >/dev/null 2>&1 && runuser -u "$user" -- true 2>/dev/null; then + as_user_tool=runuser + elif command -v sudo >/dev/null 2>&1 && sudo -n -u "$user" -- true 2>/dev/null; then + as_user_tool=sudo + else + as_user_tool=su + fi + fi + case "$as_user_tool" in + runuser) runuser -u "$user" -- "$@" ;; + sudo) sudo -n -u "$user" -- "$@" ;; + *) command=$1 shift su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" - fi + ;; + esac } python_bin=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true) diff --git a/internal/recipes/samples_test.go b/internal/recipes/samples_test.go index f493d0a0..01ae8055 100644 --- a/internal/recipes/samples_test.go +++ b/internal/recipes/samples_test.go @@ -260,6 +260,28 @@ func TestBundledPythonDevCreatesAndPreservesVenv(t *testing.T) { } } +// Alpine ships a sudoers file that grants root nothing, so sudo is on PATH and +// still refuses every command. The recipe must fall through to su instead of +// failing the runcmd and leaving cloud-init in error. +func TestBundledPythonDevFallsBackWhenEscalationRefuses(t *testing.T) { + t.Setenv("STOAT_FAKE_NO_ESCALATION", "1") + account := currentTestAccount(t) + root := t.TempDir() + + for _, guestOS := range []string{"alpine", "debian", "arch", "fedora"} { + body := bundledScript(t, "python-dev", guestOS) + venvDir := filepath.Join(root, guestOS, "env") + output, err := runBundledPython(t, body, account.Username, venvDir, + filepath.Join(root, guestOS+"-output"), filepath.Join(root, guestOS+"-calls")) + if err != nil { + t.Fatalf("%s: %v\n%s", guestOS, err, output) + } + if _, err := os.Stat(filepath.Join(venvDir, "pyvenv.cfg")); err != nil { + t.Errorf("%s: environment was not created: %v", guestOS, err) + } + } +} + func TestBundledPythonDevRefusesInvalidTargets(t *testing.T) { body := bundledScript(t, "python-dev", "alpine") account := currentTestAccount(t) @@ -449,6 +471,10 @@ func writeHermeticCommandFakes(t *testing.T, bin string) { t.Helper() accountSwitch := `#!/bin/sh set -eu +if [ -n "${STOAT_FAKE_NO_ESCALATION:-}" ]; then + echo "root is not in the sudoers file." >&2 + exit 1 +fi while [ "$#" -gt 0 ]; do case "$1" in -u|-g|-s) shift 2;; @@ -470,14 +496,14 @@ user= command= while [ "$#" -gt 0 ]; do case "$1" in + -c) command=$2; shift 2; break;; -s) shift 2;; -*) shift;; - -c) command=$2; shift 2; break;; *) user=$1; shift;; esac done [ -n "$command" ] -exec sh -c "$command" +exec sh -c "$command" "$@" `), 0o755); err != nil { t.Fatal(err) } From e27563f3c88b18f637e6e9e79fa533842ac68b8c Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 14:47:48 +0300 Subject: [PATCH 2/2] fix(recipes): quote the su fallback's command instead of passing arguments busybox su recognises options anywhere on its command line. The -p in "as_user mkdir -p /home/stoat" became su's own preserve-environment flag, so mkdir ran without it and failed on an existing directory. The runcmd failed and cloud-init ended in error on Alpine. su_user now shell-quotes the argument list into -c and passes nothing positional. The su fake in the test harness kept trailing arguments, which busybox does not. It now drops dash arguments anywhere and passes no positional arguments. Signed-off-by: NovusEdge --- .../bundled/python-dev/install-alpine.sh | 17 ++++++++++++----- .../recipes/bundled/python-dev/install-arch.sh | 17 ++++++++++++----- .../bundled/python-dev/install-debian.sh | 17 ++++++++++++----- .../recipes/bundled/python-dev/install-rpm.sh | 17 ++++++++++++----- internal/recipes/samples_test.go | 10 +++++++--- 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/internal/recipes/bundled/python-dev/install-alpine.sh b/internal/recipes/bundled/python-dev/install-alpine.sh index f783d5ca..0f46ac9b 100755 --- a/internal/recipes/bundled/python-dev/install-alpine.sh +++ b/internal/recipes/bundled/python-dev/install-alpine.sh @@ -12,6 +12,17 @@ fi stoat_pkg_setup stoat_pkg_install python3 py3-pip +# busybox su recognises options anywhere, so a -p among the trailing arguments +# becomes su's own preserve-environment flag and never reaches the command. +# Quote the argument list into -c instead of passing it positionally. +su_user() { + quoted= + for arg do + quoted="$quoted'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " + done + su -s /bin/sh "$user" -c "exec $quoted" +} + # Alpine's sudoers file grants root nothing, so sudo is on PATH and refuses # every command. Probe each tool once and keep the first that runs. as_user() { @@ -27,11 +38,7 @@ as_user() { case "$as_user_tool" in runuser) runuser -u "$user" -- "$@" ;; sudo) sudo -n -u "$user" -- "$@" ;; - *) - command=$1 - shift - su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" - ;; + *) su_user "$@" ;; esac } diff --git a/internal/recipes/bundled/python-dev/install-arch.sh b/internal/recipes/bundled/python-dev/install-arch.sh index fd4739c5..b6c55dfe 100755 --- a/internal/recipes/bundled/python-dev/install-arch.sh +++ b/internal/recipes/bundled/python-dev/install-arch.sh @@ -12,6 +12,17 @@ fi stoat_pkg_setup stoat_pkg_install python python-pip +# busybox su recognises options anywhere, so a -p among the trailing arguments +# becomes su's own preserve-environment flag and never reaches the command. +# Quote the argument list into -c instead of passing it positionally. +su_user() { + quoted= + for arg do + quoted="$quoted'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " + done + su -s /bin/sh "$user" -c "exec $quoted" +} + # A sudo binary on PATH is not a working sudo: Alpine's sudoers grants root # nothing. Probe each tool once and keep the first that runs. as_user() { @@ -27,11 +38,7 @@ as_user() { case "$as_user_tool" in runuser) runuser -u "$user" -- "$@" ;; sudo) sudo -n -u "$user" -- "$@" ;; - *) - command=$1 - shift - su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" - ;; + *) su_user "$@" ;; esac } diff --git a/internal/recipes/bundled/python-dev/install-debian.sh b/internal/recipes/bundled/python-dev/install-debian.sh index 9786ee26..195b0523 100755 --- a/internal/recipes/bundled/python-dev/install-debian.sh +++ b/internal/recipes/bundled/python-dev/install-debian.sh @@ -12,6 +12,17 @@ fi stoat_pkg_setup stoat_pkg_install python3 python3-pip python3-venv +# busybox su recognises options anywhere, so a -p among the trailing arguments +# becomes su's own preserve-environment flag and never reaches the command. +# Quote the argument list into -c instead of passing it positionally. +su_user() { + quoted= + for arg do + quoted="$quoted'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " + done + su -s /bin/sh "$user" -c "exec $quoted" +} + # A sudo binary on PATH is not a working sudo: Alpine's sudoers grants root # nothing. Probe each tool once and keep the first that runs. as_user() { @@ -27,11 +38,7 @@ as_user() { case "$as_user_tool" in runuser) runuser -u "$user" -- "$@" ;; sudo) sudo -n -u "$user" -- "$@" ;; - *) - command=$1 - shift - su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" - ;; + *) su_user "$@" ;; esac } diff --git a/internal/recipes/bundled/python-dev/install-rpm.sh b/internal/recipes/bundled/python-dev/install-rpm.sh index c6c82db1..511dbbaf 100755 --- a/internal/recipes/bundled/python-dev/install-rpm.sh +++ b/internal/recipes/bundled/python-dev/install-rpm.sh @@ -12,6 +12,17 @@ fi stoat_pkg_setup stoat_pkg_install python3 python3-pip +# busybox su recognises options anywhere, so a -p among the trailing arguments +# becomes su's own preserve-environment flag and never reaches the command. +# Quote the argument list into -c instead of passing it positionally. +su_user() { + quoted= + for arg do + quoted="$quoted'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " + done + su -s /bin/sh "$user" -c "exec $quoted" +} + # A sudo binary on PATH is not a working sudo: Alpine's sudoers grants root # nothing. Probe each tool once and keep the first that runs. as_user() { @@ -27,11 +38,7 @@ as_user() { case "$as_user_tool" in runuser) runuser -u "$user" -- "$@" ;; sudo) sudo -n -u "$user" -- "$@" ;; - *) - command=$1 - shift - su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" - ;; + *) su_user "$@" ;; esac } diff --git a/internal/recipes/samples_test.go b/internal/recipes/samples_test.go index 01ae8055..1047ac77 100644 --- a/internal/recipes/samples_test.go +++ b/internal/recipes/samples_test.go @@ -490,20 +490,24 @@ exec "$@" t.Fatal(err) } } + // busybox su, which is what Alpine has, recognises options anywhere on the + // command line. A -p among the trailing arguments becomes su's own + // preserve-environment flag and never reaches the command, so this fake + // drops every dash argument and passes nothing positional to the shell. if err := os.WriteFile(filepath.Join(bin, "su"), []byte(`#!/bin/sh set -eu user= command= while [ "$#" -gt 0 ]; do case "$1" in - -c) command=$2; shift 2; break;; + -c) command=$2; shift 2;; -s) shift 2;; -*) shift;; - *) user=$1; shift;; + *) [ -n "$user" ] || user=$1; shift;; esac done [ -n "$command" ] -exec sh -c "$command" "$@" +exec sh -c "$command" `), 0o755); err != nil { t.Fatal(err) }