diff --git a/src/artifacts-helper/README.md b/src/artifacts-helper/README.md index 16f8bcb..b5e16e1 100644 --- a/src/artifacts-helper/README.md +++ b/src/artifacts-helper/README.md @@ -26,6 +26,14 @@ Configures Codespace to authenticate with Azure Artifact feeds | pnpmAlias | Create alias for pnpm | boolean | true | | targetFiles | Comma separated list of files to write to. Default is '/etc/bash.bashrc,/etc/zsh/zshrc' for root and '~/.bashrc,~/.zshrc' for non-root | string | DEFAULT | | python | Install Python keyring helper for pip | boolean | false | +| wrapperType | Type of wrapper to use. Options are 'SHELL_FUNCTION' or 'EXECUTABLE' | string | `SHELL_FUNCTION` | + +### Wrapper Types + +| Wrapper Type | Description | +|-|-| +| `SHELL_FUNCTION` | Configures shell functions that wrap the commands to set the authentication token before calling the actual command. This is the default options. | +| `EXECUTABLE` | Configures a separate executable that wraps the commands to set the authentication token before calling the actual command. Suited for scenarios where the commands need to be invoked from scripts that do not consider shell functions, e.g. `subprocess.Popen(['npx'], shell=False)`. | ## Customizations diff --git a/src/artifacts-helper/devcontainer-feature.json b/src/artifacts-helper/devcontainer-feature.json index 389127a..ce83772 100644 --- a/src/artifacts-helper/devcontainer-feature.json +++ b/src/artifacts-helper/devcontainer-feature.json @@ -58,11 +58,24 @@ "type": "boolean", "default": false, "description": "Install Python keyring helper for pip" + }, + "wrapperType": { + "type": "string", + "default": "SHELL_FUNCTION", + "description": "Type of wrapper script to use.", + "enum": [ + "SHELL_FUNCTION", + "EXECUTABLE" + ] } }, + "containerEnv": { + "PATH": "/usr/local/share/codespace-features:${PATH}" + }, "installsAfter": [ "ghcr.io/devcontainers/features/common-utils", - "ghcr.io/devcontainers/features/python" + "ghcr.io/devcontainers/features/python", + "ghcr.io/devcontainers/features/node" ], "customizations": { "vscode": { diff --git a/src/artifacts-helper/install.sh b/src/artifacts-helper/install.sh index d460f94..514c061 100755 --- a/src/artifacts-helper/install.sh +++ b/src/artifacts-helper/install.sh @@ -4,6 +4,7 @@ set -e PREFIXES="${NUGETURIPREFIXES:-"https://pkgs.dev.azure.com/"}" USENET6="${DOTNET6:-"false"}" +WRAPPERTYPE="${WRAPPERTYPE:-"SHELL_FUNCTION"}" ALIAS_DOTNET="${DOTNETALIAS:-"true"}" ALIAS_NUGET="${NUGETALIAS:-"true"}" ALIAS_NPM="${NPMALIAS:-"true"}" @@ -14,30 +15,35 @@ ALIAS_PNPM="${PNPMALIAS:-"true"}" INSTALL_PIP_HELPER="${PYTHON:-"false"}" COMMA_SEP_TARGET_FILES="${TARGETFILES:-"DEFAULT"}" -ALIASES_ARR=() +# Destination to install wrappers for the `EXECUTABLE` wrapper type. +# This path must take precedence over /usr/local/bin and the original tools, +# which is ensured via `containerEnv.PATH` in devcontainer-feature.json. +EXECUTABLES_TARGET_DIR='/usr/local/share/codespace-features' + +WRAPPED_BINS_ARR=() if [ "${ALIAS_DOTNET}" = "true" ]; then - ALIASES_ARR+=('dotnet') + WRAPPED_BINS_ARR+=('dotnet') fi if [ "${ALIAS_NUGET}" = "true" ]; then - ALIASES_ARR+=('nuget') + WRAPPED_BINS_ARR+=('nuget') fi if [ "${ALIAS_NPM}" = "true" ]; then - ALIASES_ARR+=('npm') + WRAPPED_BINS_ARR+=('npm') fi if [ "${ALIAS_YARN}" = "true" ]; then - ALIASES_ARR+=('yarn') + WRAPPED_BINS_ARR+=('yarn') fi if [ "${ALIAS_NPX}" = "true" ]; then - ALIASES_ARR+=('npx') + WRAPPED_BINS_ARR+=('npx') fi if [ "${ALIAS_RUSH}" = "true" ]; then - ALIASES_ARR+=('rush') - ALIASES_ARR+=('rush-pnpm') + WRAPPED_BINS_ARR+=('rush') + WRAPPED_BINS_ARR+=('rush-pnpm') fi if [ "${ALIAS_PNPM}" = "true" ]; then - ALIASES_ARR+=('pnpm') - ALIASES_ARR+=('pnpx') + WRAPPED_BINS_ARR+=('pnpm') + WRAPPED_BINS_ARR+=('pnpx') fi # Source /etc/os-release to get OS info @@ -116,27 +122,46 @@ if command -v sudo >/dev/null 2>&1; then fi fi -if [ "${COMMA_SEP_TARGET_FILES}" = "DEFAULT" ]; then - if [ "${INSTALL_WITH_SUDO}" = "true" ]; then - COMMA_SEP_TARGET_FILES="~/.bashrc,~/.zshrc" - else - COMMA_SEP_TARGET_FILES="/etc/bash.bashrc,/etc/zsh/zshrc" - fi -fi - -IFS=',' read -r -a TARGET_FILES_ARR <<< "$COMMA_SEP_TARGET_FILES" - -for ALIAS in "${ALIASES_ARR[@]}"; do - for TARGET_FILE in "${TARGET_FILES_ARR[@]}"; do - CMD="$ALIAS() { /usr/local/bin/run-$ALIAS.sh \"\$@\"; }" +if [ "$WRAPPERTYPE" = "SHELL_FUNCTION" ]; then + echo "Installing shell functions for wrapped commands." + if [ "${COMMA_SEP_TARGET_FILES}" = "DEFAULT" ]; then if [ "${INSTALL_WITH_SUDO}" = "true" ]; then - sudo -u ${_REMOTE_USER} bash -c "echo '$CMD' >> $TARGET_FILE" + COMMA_SEP_TARGET_FILES="~/.bashrc,~/.zshrc" else - echo $CMD >> $TARGET_FILE || true + COMMA_SEP_TARGET_FILES="/etc/bash.bashrc,/etc/zsh/zshrc" fi + fi + + IFS=',' read -r -a TARGET_FILES_ARR <<< "$COMMA_SEP_TARGET_FILES" + + for ALIAS in "${WRAPPED_BINS_ARR[@]}"; do + for TARGET_FILE in "${TARGET_FILES_ARR[@]}"; do + CMD="$ALIAS() { /usr/local/bin/run-$ALIAS.sh \"\$@\"; }" + + if [ "${INSTALL_WITH_SUDO}" = "true" ]; then + sudo -u ${_REMOTE_USER} bash -c "echo '$CMD' >> $TARGET_FILE" + else + echo $CMD >> $TARGET_FILE || true + fi + done + done +elif [ "$WRAPPERTYPE" = "EXECUTABLE" ]; then + echo "Installing executable scripts for wrapped commands." + sudo mkdir -p "${EXECUTABLES_TARGET_DIR}" + + for ALIAS in "${WRAPPED_BINS_ARR[@]}"; do + TARGET_EXECUTABLE_PATH="${EXECUTABLES_TARGET_DIR}/$ALIAS" + TARGET_WRAPPER_PATH="/usr/local/bin/run-$ALIAS.sh" + TARGET_WRAPPER_EXE_ENV_VAR_NAME=$(sed "s|-|_|g" <<<"${ALIAS}_EXE" | tr '[:lower:]' '[:upper:]') + cp ./scripts/executable-wrapper-template.sh "$TARGET_EXECUTABLE_PATH" + sed -i "s|SUBST_TARGET_WRAPPER_PATH|$TARGET_WRAPPER_PATH|g; s|SUBST_TARGET_BIN_NAME|$ALIAS|g; s|SUBST_EXE_ENV_VAR|$TARGET_WRAPPER_EXE_ENV_VAR_NAME|g" "$TARGET_EXECUTABLE_PATH" + chmod +x "$TARGET_EXECUTABLE_PATH" done -done +else + echo "Invalid WRAPPERTYPE specified: $WRAPPERTYPE. Must be one of SHELL_FUNCTION or EXECUTABLE." + exit 1 +fi if [ "${INSTALL_WITH_SUDO}" = "true" ]; then sudo -u ${_REMOTE_USER} bash -c "/tmp/install-provider.sh ${USENET6}" diff --git a/src/artifacts-helper/scripts/executable-wrapper-template.sh b/src/artifacts-helper/scripts/executable-wrapper-template.sh new file mode 100644 index 0000000..136b897 --- /dev/null +++ b/src/artifacts-helper/scripts/executable-wrapper-template.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# Recursively unwrap itself from the path, eventually calling the target binary's +# `run-.sh` script, which will inject a feed authentication token. + +TARGET_BIN_NAME=SUBST_TARGET_BIN_NAME +TARGET_WRAPPER_PATH=SUBST_TARGET_WRAPPER_PATH + +log() { + # Print to stderr to avoid interfering with scripts that capture stdout. + # Prefix error messages so the user can quickly determine that it's from our + # artifacts-helper wrapper, not an error originating from the wrapped binary. + printf >&2 "[codespace-features/artifacts-helper] %s" "$1" +} + +log_line() { + log "$1 +" +} + +log_debug_line() { + if [ "$ARTIFACTS_HELPER_DEBUG" == "1" ]; then + log_line "$1" + fi +} + +call_wrapped_bin() { + CURRENT_SCRIPT_PATH=$(realpath "${BASH_SOURCE[0]}") + if [ -z "$CURRENT_SCRIPT_PATH" ]; then + log_line "Error: Current script path could not be determined! This will result in infinite recursion. Bailing early." + exit 1 + fi + + # Find the next executable from the PATH order, which would be shadowed by this wrapper + if [ -z "$ARTIFACTS_HELPER_TARGET_BIN_PATHS" ]; then + ARTIFACTS_HELPER_TARGET_BIN_PATHS=$(which -a "$TARGET_BIN_NAME") + fi + + ARTIFACTS_HELPER_TARGET_BIN_PATHS=$(sed '/^[[:space:]]*$/d' <<<"$ARTIFACTS_HELPER_TARGET_BIN_PATHS") + ARTIFACTS_HELPER_TARGET_BIN_PATHS=$(grep --color=never -Fve "$CURRENT_SCRIPT_PATH" <<<"$ARTIFACTS_HELPER_TARGET_BIN_PATHS") + NEXT_SHADOWED_BIN=$(head -n 1 <<<"$ARTIFACTS_HELPER_TARGET_BIN_PATHS") + + log_debug_line "CURRENT_SCRIPT_PATH=$CURRENT_SCRIPT_PATH" + log_debug_line "TARGET_BIN_NAME=$TARGET_BIN_NAME" + log_debug_line "TARGET_WRAPPER_PATH=$TARGET_WRAPPER_PATH" + log_debug_line "ARTIFACTS_HELPER_TARGET_BIN_PATHS=$ARTIFACTS_HELPER_TARGET_BIN_PATHS" + log_debug_line "NEXT_SHADOWED_BIN=$NEXT_SHADOWED_BIN" + + if [ -z "$NEXT_SHADOWED_BIN" ]; then + log_line "Error: The real $TARGET_BIN_NAME could not be found on PATH." + log_line "which -a $TARGET_BIN_NAME:\n%s\n" "$(which -a $TARGET_BIN_NAME)" + log_line "CC_SHADOWED_BINS:\n%s\n" "$ARTIFACTS_HELPER_TARGET_BIN_PATHS" + log_line "CURRENT_SCRIPT_PATH: %s\n" "$CURRENT_SCRIPT_PATH" + exit 1 + fi + + # Recursively removing the wrapper script(s) from the shadowed bin paths will + # allow us to account for circumstances where the wrapper script appears multiple + # times on the PATH. We will eventually descend to the real target binary. + export ARTIFACTS_HELPER_TARGET_BIN_PATHS + + log_debug_line "Running: SUBST_EXE_ENV_VAR=$NEXT_SHADOWED_BIN $TARGET_WRAPPER_PATH" + SUBST_EXE_ENV_VAR="$NEXT_SHADOWED_BIN" "$TARGET_WRAPPER_PATH" "$@" + return "$?" +} + +call_wrapped_bin "$@" +EXIT_CODE=$? +exit $EXIT_CODE diff --git a/src/artifacts-helper/scripts/run-dotnet.sh b/src/artifacts-helper/scripts/run-dotnet.sh index 667caa8..3035e02 100755 --- a/src/artifacts-helper/scripts/run-dotnet.sh +++ b/src/artifacts-helper/scripts/run-dotnet.sh @@ -11,7 +11,9 @@ if [ -f "${HOME}/ado-auth-helper" ]; then fi # Find the dotnet executable so we do not run the bash alias again -DOTNET_EXE=$(which dotnet) +if [ -z "$DOTNET_EXE" ]; then + DOTNET_EXE=$(which dotnet) +fi ${DOTNET_EXE} "$@" EXIT_CODE=$? diff --git a/src/artifacts-helper/scripts/run-npm.sh b/src/artifacts-helper/scripts/run-npm.sh index 469c5a9..b979cfb 100755 --- a/src/artifacts-helper/scripts/run-npm.sh +++ b/src/artifacts-helper/scripts/run-npm.sh @@ -4,8 +4,10 @@ if [ -f "${HOME}/ado-auth-helper" ]; then export ARTIFACTS_ACCESSTOKEN=$(${HOME}/ado-auth-helper get-access-token) fi -# Find the npm executable so we do not run the bash alias again -NPM_EXE=$(which npm) +if [ -z "$NPM_EXE" ]; then + # Find the npm executable so we do not run the bash alias again + NPM_EXE=$(which npm) +fi ${NPM_EXE} "$@" EXIT_CODE=$? diff --git a/src/artifacts-helper/scripts/run-npx.sh b/src/artifacts-helper/scripts/run-npx.sh index 932f621..c1535e9 100755 --- a/src/artifacts-helper/scripts/run-npx.sh +++ b/src/artifacts-helper/scripts/run-npx.sh @@ -4,8 +4,10 @@ if [ -f "${HOME}/ado-auth-helper" ]; then export ARTIFACTS_ACCESSTOKEN=$(${HOME}/ado-auth-helper get-access-token) fi -# Find the npm executable so we do not run the bash alias again -NPX_EXE=$(which npx) +# Find the npx executable so we do not run the bash alias again +if [ -z "$NPX_EXE" ]; then + NPX_EXE=$(which npx) +fi ${NPX_EXE} "$@" EXIT_CODE=$? diff --git a/src/artifacts-helper/scripts/run-nuget.sh b/src/artifacts-helper/scripts/run-nuget.sh index 368dfcb..01bbc6d 100755 --- a/src/artifacts-helper/scripts/run-nuget.sh +++ b/src/artifacts-helper/scripts/run-nuget.sh @@ -10,8 +10,10 @@ if [ -f "${HOME}/ado-auth-helper" ]; then export VSS_NUGET_URI_PREFIXES=REPLACE_WITH_AZURE_DEVOPS_NUGET_FEED_URL_PREFIX fi -# Find the dotnet executable so we do not run the bash alias again -NUGET_EXE=$(which nuget) +# Find the nuget executable so we do not run the bash alias again +if [ -z "$NUGET_EXE" ]; then + NUGET_EXE=$(which nuget) +fi ${NUGET_EXE} "$@" EXIT_CODE=$? diff --git a/src/artifacts-helper/scripts/run-pnpm.sh b/src/artifacts-helper/scripts/run-pnpm.sh index 9a2bad2..7fc3d5d 100644 --- a/src/artifacts-helper/scripts/run-pnpm.sh +++ b/src/artifacts-helper/scripts/run-pnpm.sh @@ -5,7 +5,9 @@ if [ -f "${HOME}/ado-auth-helper" ]; then fi # Find the pnpm executable so we do not run the bash alias again -PNPM_EXE=$(which pnpm) +if [ -z "$PNPM_EXE" ]; then + PNPM_EXE=$(which pnpm) +fi ${PNPM_EXE} "$@" EXIT_CODE=$? diff --git a/src/artifacts-helper/scripts/run-pnpx.sh b/src/artifacts-helper/scripts/run-pnpx.sh index 02572ad..33c7937 100644 --- a/src/artifacts-helper/scripts/run-pnpx.sh +++ b/src/artifacts-helper/scripts/run-pnpx.sh @@ -5,7 +5,9 @@ if [ -f "${HOME}/ado-auth-helper" ]; then fi # Find the pnpx executable so we do not run the bash alias again -PNPX_EXE=$(which pnpx) +if [ -z "$PNPX_EXE" ]; then + PNPX_EXE=$(which pnpx) +fi ${PNPX_EXE} "$@" EXIT_CODE=$? diff --git a/src/artifacts-helper/scripts/run-rush-pnpm.sh b/src/artifacts-helper/scripts/run-rush-pnpm.sh index ec3cdc7..6e978f0 100644 --- a/src/artifacts-helper/scripts/run-rush-pnpm.sh +++ b/src/artifacts-helper/scripts/run-rush-pnpm.sh @@ -5,7 +5,9 @@ if [ -f "${HOME}/ado-auth-helper" ]; then fi # Find the rush-pnpm executable so we do not run the bash alias again -RUSH_PNPM_EXE=$(which rush-pnpm) +if [ -z "$RUSH_PNPM_EXE" ]; then + RUSH_PNPM_EXE=$(which rush-pnpm) +fi ${RUSH_PNPM_EXE} "$@" EXIT_CODE=$? diff --git a/src/artifacts-helper/scripts/run-rush.sh b/src/artifacts-helper/scripts/run-rush.sh index ddc7b4f..037ccac 100644 --- a/src/artifacts-helper/scripts/run-rush.sh +++ b/src/artifacts-helper/scripts/run-rush.sh @@ -5,7 +5,9 @@ if [ -f "${HOME}/ado-auth-helper" ]; then fi # Find the rush executable so we do not run the bash alias again -RUSH_EXE=$(which rush) +if [ -z "$RUSH_EXE" ]; then + RUSH_EXE=$(which rush) +fi ${RUSH_EXE} "$@" EXIT_CODE=$? diff --git a/src/artifacts-helper/scripts/run-yarn.sh b/src/artifacts-helper/scripts/run-yarn.sh index a414c02..d719577 100755 --- a/src/artifacts-helper/scripts/run-yarn.sh +++ b/src/artifacts-helper/scripts/run-yarn.sh @@ -5,7 +5,9 @@ if [ -f "${HOME}/ado-auth-helper" ]; then fi # Find the yarn executable so we do not run the bash alias again -YARN_EXE=$(which yarn) +if [ -z "$YARN_EXE" ]; then + YARN_EXE=$(which yarn) +fi ${YARN_EXE} "$@" EXIT_CODE=$? diff --git a/test/artifacts-helper/scenario_executable_wrapper.sh b/test/artifacts-helper/scenario_executable_wrapper.sh new file mode 100755 index 0000000..4dc009b --- /dev/null +++ b/test/artifacts-helper/scenario_executable_wrapper.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +set -e + +WRAPPER_INSTALL_PATH='/usr/local/share/codespace-features' +BINS_TO_CHECK=(npm npx pnpm pnpx rush-pnpm rush yarn) + +check_path_priority() { + echo "Checking PATH priority" + + for BIN_NAME in "${BINS_TO_CHECK[@]}"; do + if ! command -v "$BIN_NAME" &>/dev/null; then + echo "Error: $BIN_NAME not found in PATH" + exit 1 + fi + + # Check if the target binary is wrapped + ACTUAL_BIN_PATH=$(command -v "$BIN_NAME") + EXPECTED_BIN_PATH="$WRAPPER_INSTALL_PATH/$BIN_NAME" + if ! grep -q "$EXPECTED_BIN_PATH" <<<"$ACTUAL_BIN_PATH"; then + echo "Error: $BIN_NAME is not wrapped. We expected $EXPECTED_BIN_PATH but the actual binary path is $ACTUAL_BIN_PATH." + echo "Please contact Clipchamp EngProd and let them know that the feed-auth-wrapper feature is not working as expected." + exit 1 + fi + + echo "Success: $BIN_NAME is wrapped." + done +} + +check_bin_exec() { + echo "Checking if the wrapped binaries get executed" + + cat <"$HOME/ado-auth-helper" +#!/usr/bin/env bash + +echo "dummy-token" +EOF + chmod +x "$HOME/ado-auth-helper" + + WRAPPED_BINS_DIR=$(mktemp -d) + BASH_BIN_DIR=$(dirname "$(command -v bash)") + TEST_PATH="$WRAPPER_INSTALL_PATH:$WRAPPER_INSTALL_PATH:$WRAPPED_BINS_DIR:$BASH_BIN_DIR" + + for BIN_NAME in "${BINS_TO_CHECK[@]}"; do + echo "Checking $BIN_NAME" + echo "Creating a temporary binary to be shadowed by the wrapper" + WRAPPED_BIN="$WRAPPED_BINS_DIR/$BIN_NAME" + expected_stdout="Hello from $BIN_NAME" + cat <"$WRAPPED_BIN" +#!/usr/bin/env bash +if [ -z "\$ARTIFACTS_ACCESSTOKEN" ]; then + echo >&2 "Error: ARTIFACTS_ACCESSTOKEN was not set! It should be set by the artifacts-helper wrapper." + exit 1 +fi +if [ "\$ARTIFACTS_ACCESSTOKEN" != "dummy-token" ]; then + echo >&2 "Error: ARTIFACTS_ACCESSTOKEN was set to an unexpected value: \$ARTIFACTS_ACCESSTOKEN" + exit 1 +fi +echo "$expected_stdout" +EOF + chmod +x "$WRAPPED_BIN" + + echo "Executing $BIN_NAME to check if it wraps the temporary binary" + status_code=0 + actual_stdout="$(PATH="$TEST_PATH" ARTIFACTS_HELPER_DEBUG=1 "$BIN_NAME")" || status_code=$? + if [ $status_code -ne 0 ]; then + echo "Error: wrapper for $BIN_NAME exited non-zero with $status_code." + exit 1 + fi + + echo "Checking the output of the wrapper" + echo "stdout: $actual_stdout" + if [ "$actual_stdout" = "$expected_stdout" ]; then + echo "Success: wrapper for $BIN_NAME executed correctly." + else + echo "Error: wrapper for $BIN_NAME did not execute correctly. Expected '$expected_stdout' but got '$actual_stdout'." + exit 1 + fi + done + + rm -r "$WRAPPED_BINS_DIR" + echo "Success: Wrapped binaries are executed correctly." +} + +main() { + echo "Starting scenario_executable_wrapper tests" + + check_path_priority + check_bin_exec + + echo "scenario_executable_wrapper tests completed successfully" +} + +main diff --git a/test/artifacts-helper/scenarios.json b/test/artifacts-helper/scenarios.json index 67178f7..500c461 100644 --- a/test/artifacts-helper/scenarios.json +++ b/test/artifacts-helper/scenarios.json @@ -42,5 +42,13 @@ "python": false } } + }, + "scenario_executable_wrapper": { + "image": "mcr.microsoft.com/devcontainers/base:debian", + "features": { + "artifacts-helper": { + "wrapperType": "EXECUTABLE" + } + } } } \ No newline at end of file