Is this a valid self-update approach for a bash script?

(At least it doesn't try to continue running after updating itself!)

The thing that makes me nervous about your approach is that you're overwriting the current script (mv $0.tmp $0) as it's running. There are a number of reasons why this will probably work, but I wouldn't bet large amounts that it's guaranteed to work in all circumstances. I don't know of anything in POSIX or any other standard that specifies how the shell processes a file that it's executing as a script.

Here's what's probably going to happen:

You execute the script. The kernel sees the #!/bin/sh line (you didn't show it, but I presume it's there) and invokes /bin/sh with the name of your script as an argument. The shell then uses fopen(), or perhaps open() to open your script, reads from it, and starts interpreting its contents as shell commands.

For a sufficiently small script, the shell probably just reads the whole thing into memory, either explicitly or as part of the buffering done by normal file I/O. For a larger script, it might read it in chunks as it's executing. But either way, it probably only opens the file once, and keeps it open as long as it's executing.

If you remove or rename a file, the actual file is not necessarily immediately erased from disk. If there's another hard link to it, or if some process has it open, the file continues to exist, even though it may no longer be possible for another process to open it under the same name, or at all. The file is not physically deleted until the last link (directory entry) that refers to it has been removed, and no processes have it open. (Even then, its contents won't immediately be erased, but that's going beyond what's relevant here.)

And furthermore, the mv command that clobbers the script file is immediately followed by exit 0.

BUT it's at least conceivable that the shell could close the file and then re-open it by name. I can't think of any good reason for it to do so, but I know of no absolute guarantee that it won't.

And some systems tend to do stricter file locking that most Unix systems do. On Windows, for example, I suspect that the mv command would fail because a process (the shell) has the file open. Your script might fail on Cygwin. (I haven't tried it.)

So what makes me nervous is not so much the small possibility that it could fail, but the long and tenuous line of reasoning that seems to demonstrate that it will probably succeed, and the very real possibility that there's something else I haven't thought of.

My suggestion: write a second script whose one and only job is to update the first. Put the runSelfUpdate() function, or equivalent code, into that script. In your original script, use exec to invoke the update script, so that the original script is no longer running when you update it. If you want to avoid the hassle of maintaining, distributing, and installing two separate scripts. you could have the original script create the update script with a unique in /tmp; that would also solve the problem of updating the update script. (I wouldn't worry about cleaning up the autogenerated update script in /tmp; that would just reopen the same can of worms.)


Very late answer here, but as I just solved this too, I thought it might help someone to post the approach:

#!/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

Yes, but ... I would recommend you keep a more layered version of your script's history, unless the remote host can also perform version-control with histories. That being said, to respond directly to the code you have posted, see the following comments ;-)

What happens to your system when wget has a hiccup, quietly overwrites part of your working script with only a partial or otherwise corrupt copy? Your next step does a mv $0.tmp $0 so you've lost your working version. (I hope you have it in version control on the remote!)

You can check to see if wget returns any error messages

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

Also, Rule-of-thumb tests will help, i.e.

if (( $(wc -c < $0.tmp) >= $(wc -c < $0) )); then 
    mv $0.tmp $0
fi

but are hardly foolproof.

If your $0 could windup with spaces in it, better to surround all references like "$0".

To be super-bullet proof, consider checking all command returns AND that Octal_Mode has a reasonable value

  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

I hope this helps.

Tags:

Bash