这是一个适用于Bash脚本的有效自更新方法吗?

21

我正在开发一个脚本,它变得非常复杂,因此我想包含一个简单选项来将其更新为最新版本。这是我的方法:

set -o errexit

SELF=$(basename $0)
UPDATE_BASE=http://something

runSelfUpdate() {
  echo "Performing self-update..."
  # Download new version
  wget --quiet --output-document=$0.tmp $UPDATE_BASE/$SELF
  # Copy over modes from old version
  OCTAL_MODE=$(stat -c '%a' $0)
  chmod $OCTAL_MODE $0.tmp
  # Overwrite old file with new
  mv $0.tmp $0
  exit 0
}

脚本似乎按预期工作,但我想知道这种方法是否存在任何注意事项。我很难相信一个脚本可以在没有后果的情况下覆盖自身。

更明确地说,我想知道 bash 是否会逐行读取并执行脚本,在mv之后,exit 0可能会变成新脚本中的其他内容。我记得 Windows 在处理.bat文件时会像这样表现。

更新:我的原始代码片段没有包含set -o errexit。据我了解,这应该可以保护我免受由wget引起的问题。
此外,在这种情况下,UPDATE_BASE指向版本控制下的位置(以缓解担忧)。

结果:根据这些答案的输入,我构建了这个修订后的方法:

runSelfUpdate() {
  echo "Performing self-update..."

  # Download new version
  echo -n "Downloading latest version..."
  if ! wget --quiet --output-document="$0.tmp" $UPDATE_BASE/$SELF ; then
    echo "Failed: Error while trying to wget new version!"
    echo "File requested: $UPDATE_BASE/$SELF"
    exit 1
  fi
  echo "Done."

  # Copy over modes from old version
  OCTAL_MODE=$(stat -c '%a' $SELF)
  if ! chmod $OCTAL_MODE "$0.tmp" ; then
    echo "Failed: Error while trying to set mode on $0.tmp."
    exit 1
  fi

  # Spawn update script
  cat > updateScript.sh << EOF
#!/bin/bash
# Overwrite old file with new
if mv "$0.tmp" "$0"; then
  echo "Done. Update complete."
  rm \$0
else
  echo "Failed!"
fi
EOF

  echo -n "Inserting update process..."
  exec /bin/bash updateScript.sh
}

exec "$0"加上需要中继的任何参数(也许是:exec "$0" "$@")替换exit 0不是更好吗?也就是说,在处理下载、验证和替换@shellter所讨论的脚本的前身的机制之后。 - Jonathan Leffler
@JonathanLeffler 是的,也许吧。目前,我想将其保持为单独且简短的操作,以便不会干扰脚本的其他部分。一旦我对这种方法有信心,我可能会扩展它。 - Oliver Salzburg
请注意,在OSX上使用stat获取权限需要不同的标志和格式。相当于stat -f'%A' $0 - Leon S.
3个回答

8
(至少它不会在更新后继续运行!)你的做法让我有点紧张,因为你正在覆盖当前脚本(mv $0.tmp $0),而它正在运行。有很多原因可以解释它可能工作,但我不能保证在所有情况下都能成功。我不知道 POSIX 或任何其他标准规定了 shell 如何处理运行为脚本的文件。
以下是可能发生的情况:
您执行脚本。内核看到 #!/bin/sh 行(虽然您没有显示它,但我认为它存在),并使用您的脚本名称作为参数调用 /bin/sh。然后,shell 使用 fopen() 或者可能是 open() 打开您的脚本,并从中读取内容,开始将其解释为 shell 命令。
对于足够小的脚本,shell 可能会将整个脚本读入内存,或显式或作为正常文件 I/O 所进行的缓冲区的一部分。对于较大的脚本,它可能会在执行时分块读取它。但无论哪种方式,它可能仅打开文件一次,并在执行期间保持打开状态。
如果您删除或重命名文件,则实际文件不一定会立即从磁盘上删除。如果还有其他硬链接指向它,或者某些进程仍在使用它,则文件仍将存在,即使可能不再能够使用相同的名称或根本无法打开该文件。除非删除了最后一个引用它的链接(目录项),并且没有任何进程正在使用它,否则文件不会被物理删除。(即使这样,它的内容也不会立即被删除,但这超出了此处相关的范围。)
而且,覆盖脚本文件的 mv 命令紧跟着 exit 0
但是,shell 可能会关闭文件,然后按名称重新打开它,这是至少可以想象的事情。我无法想到它这样做的好处,但我知道没有绝对的保证它不会这样做。
某些系统倾向于执行比大多数 Unix 系统更严格的文件锁定。例如,在 Windows 上,我怀疑 mv 命令会失败,因为某个进程(shell)已经打开了该文件。您的脚本可能会在 Cygwin 上失败。(我没有尝试过。)
让我感到紧张的不是它可能失败的小可能性,而是似乎表明它很可能成功的漫长而脆弱的推理线路,以及我没有想到的其他可能性的现实可能性。
我的建议是:编写第二个脚本,其唯一的任务是更新第一个脚本。将 runSelfUpdate() 函数或等效代码放入该脚本中。在原始脚本中,使用 exec 调用更新脚本,使原始脚本在更新时不再运行。如果您想避免维护、分发和安装两个独立的脚本的麻烦,可以让原始脚本在 /tmp 中创建唯一的更新脚本;这也将解决更新更新脚本的问题。(我不会担心清理 /tmp 中自动生成的更新脚本;那只会重新打开同样的问题。)

这正是我所思考的。而且我想不惜一切代价将所有内容都保留在一个文件中,因此我认为我会采用两步方法。 - Oliver Salzburg

4

是的,但是我建议您保留一个更加分层的脚本历史版本,除非远程主机也能够进行带有历史记录的版本控制。话虽如此,针对您发布的代码做出直接回应,请参阅以下注释 ;-)

如果wget遇到问题,并且安静地用部分或其他损坏的副本覆盖了您工作中的脚本,那么您的系统会发生什么? 您的下一步是mv $0.tmp $0,因此您已失去了工作版本(希望您在远程上进行了版本控制!)

您可以检查是否有任何错误消息返回。

 if ! wget --quiet --output-document=$0.tmp $UPDATE_BASE/$SELF ; then
    echo "error on wget on $UPDATE_BASE/$SELF" 
    exit 1
 fi

此外,经验法则测试也会有所帮助,即:
if (( $(wc -c < $0.tmp) >= $(wc -c < $0) )); then 
    mv $0.tmp $0
fi

但它们并不是绝对可靠的。

如果你的$0可能包含空格,最好将所有引用都包围在"$0"中。

为了更加安全,考虑检查所有命令返回的值以及八进制模式是否具有合理的值。

  OCTAL_MODE=$(stat -c '%a' $0)
  case ${OCTAL_MODE:--1} in
      -[1] ) 
        printf "Error : OCTAL_MODE was empty\n"
        exit 1
     ;;       
     777|775|755 ) : nothing ;;
     * ) 
        printf "Error in OCTAL_MODEs, found value=${OCTAL_MODE}\n"
        exit 1
     ;;         
  esac

  if  ! chmod $OCTAL_MODE $0.tmp ; then
    echo "error on chmod $OCTAL_MODE %0.tmp from $UPDATE_BASE/$SELF, can't continue" 
    exit 1
 fi

我希望这能有所帮助。

非常感谢您提供的宝贵意见。但是,我在撰写问题时并不太担心下载错误。因此,我更想知道bash将如何处理脚本。我会相应地更新我的问题。 - Oliver Salzburg

3

非常晚的回答,但是我刚刚也解决了这个问题,我想发布一下我的方法,希望可以帮助到有需要的人:

#!/usr/bin/env bash
#
set -fb

readonly THISDIR=$(cd "$(dirname "$0")" ; pwd)
readonly MY_NAME=$(basename "$0")
readonly FILE_TO_FETCH_URL="https://your_url_to_downloadable_file_here"
readonly EXISTING_SHELL_SCRIPT="${THISDIR}/somescript.sh"
readonly EXECUTABLE_SHELL_SCRIPT="${THISDIR}/.somescript.sh"

function get_remote_file() {
  readonly REQUEST_URL=$1
  readonly OUTPUT_FILENAME=$2
  readonly TEMP_FILE="${THISDIR}/tmp.file"
  if [ -n "$(which wget)" ]; then
    $(wget -O "${TEMP_FILE}"  "$REQUEST_URL" 2>&1)
    if [[ $? -eq 0 ]]; then
      mv "${TEMP_FILE}" "${OUTPUT_FILENAME}"
      chmod 755 "${OUTPUT_FILENAME}"
    else
      return 1
    fi
  fi
}
function clean_up() {
  # clean up code (if required) that has to execute every time here
}
function self_clean_up() {
  rm -f "${EXECUTABLE_SHELL_SCRIPT}"
}

function update_self_and_invoke() {
  get_remote_file "${FILE_TO_FETCH_URL}" "${EXECUTABLE_SHELL_SCRIPT}"
  if [ $? -ne 0 ]; then
    cp "${EXISTING_SHELL_SCRIPT}" "${EXECUTABLE_SHELL_SCRIPT}"
  fi
  exec "${EXECUTABLE_SHELL_SCRIPT}" "$@"
}
function main() {
  cp "${EXECUTABLE_SHELL_SCRIPT}" "${EXISTING_SHELL_SCRIPT}"
  # your code here
} 

if [[ $MY_NAME = \.* ]]; then
  # invoke real main program
  trap "clean_up; self_clean_up" EXIT
  main "$@"
else
  # update myself and invoke updated version
  trap clean_up EXIT
  update_self_and_invoke "$@"
fi

感谢分享。正常工作。 - Pi Home Server

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接