Wren Linux

wren-linux / update.sh

#!/bin/bash
# Brings Wren Linux up to date: fetches what is new, shows it, and (only if you
# say yes) merges it; then lists the parts and extras you have set up whose
# files changed and offers to run them again, one at a time. Running a script
# again is how a change gets installed (/usr/local, the firewall, Firefox's
# policy...), and it is safe: scripts repair, never remove.
#
# An extra Wren Linux retired (its "# retired:" line says why) is not run
# again: it asks whether to take each one you still have out with its own
# --remove, and Enter keeps it (it asks again next time). Apart from that it
# never runs --remove, never merges anything but a plain fast-forward (so it
# can't overwrite your own changes), and asks before each step.
#
# It remembers the commit it last brought you to (~/.local/state/wren/
# updated-to); a script is offered when a file it uses changed since then.
# The first time, it takes what you have now as already applied: for what you
# pulled earlier by hand, use --since COMMIT, or --all for everything set up.
#
# Once pins.sh names Wren Linux's signing key (WREN_SIGNING_FPR), it brings
# in only a newest commit with a good signature from that key.
#
# Each time, it also checks the signing keys of the apt repositories Wren Linux
# added (lib/apt-keys.sh), and fetches a pinned key again when it has expired
# or will soon.
#
# With no server to follow (a copy brought up to date by hand, with a git bundle),
# or one it can't reach, it says so plainly, brings nothing in, and offers what
# changed in this copy since it last ran, as it is.
#
# In the Wren Linux package's copy (/usr/share/wren: root's, no git) apt brings
# what is new; update.sh then finds what changed against a record of every
# file it keeps after each run, and offers those scripts in the same way.
#
# Usage: bash update.sh [--since COMMIT | --all]
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
. "$WREN_DIR/lib/update.sh"
# (its signature helpers check Wren Linux's own commits too: update_signed)
. "$WREN_DIR/lib/appimage.sh"
. "$WREN_DIR/lib/apt-keys.sh"
not_root

STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/wren"
STATE="$STATE_DIR/updated-to"
# (a test replaces bash with something that only writes down what it was asked to run)
RUNNER=${WREN_UPDATE_RUNNER:-bash}

g() { git -C "$WREN_DIR" "$@"; }

main() {
    local since='' everything=false
    case "${1:-}" in
        --since) since=${2:-}; [ -n "$since" ] || die "Usage: bash update.sh [--since COMMIT | --all]" ;;
        --all) everything=true ;;
        "") ;;
        *) die "Usage: bash update.sh [--since COMMIT | --all]" ;;
    esac

    if ! g rev-parse --is-inside-work-tree >/dev/null 2>&1; then
        [ ! -w "$WREN_DIR" ] ||
            die "$WREN_DIR isn't a git checkout yet (a copy from a stick or a download). Connect it to the published Wren Linux first: bash $WREN_DIR/git-helper.sh"
        # The Wren Linux package's copy: root's, no git. apt brings what is new;
        # this reruns what it changed
        [ -z "$since" ] || die "--since is for a git copy of Wren Linux; the package's has no commits."
        package_offers "$everything"
        run_offered
        return
    fi
    # A hooks folder that came in with the update would run as you
    [ -z "$(g config core.hooksPath)" ] ||
        die "core.hooksPath is set in $WREN_DIR; remove it first: git -C $WREN_DIR config --unset core.hooksPath"
    # No server, or none reached: nothing new comes in, but what this copy already has (brought
    # in by hand, a git bundle say) is still offered below, as it is
    local before upstream offline=''
    if ! g rev-parse '@{upstream}' >/dev/null 2>&1; then
        offline="This copy of Wren Linux doesn't follow a branch on a server, so no newer Wren Linux was looked for.
   Once it has one: git -C $WREN_DIR branch --set-upstream-to=origin/main (vps-client.sh --origin does it, for your own server)"
    else
        say "Looking for a newer Wren Linux"
        g fetch --quiet || offline="Couldn't reach the server (is the network up?), so no newer Wren Linux was looked for."
    fi
    before=$(g rev-parse HEAD)
    if [ -n "$offline" ]; then
        warn "$offline"
        note "Nothing is brought in. What changed in this copy since update.sh last ran is offered below, as it is."
        upstream=$before
    else
        upstream=$(g rev-parse '@{upstream}')
    fi
    if [ "$before" != "$upstream" ]; then
        if g merge-base --is-ancestor "$upstream" "$before"; then
            note "You are ahead of the server; nothing to bring in."
        elif ! g merge-base --is-ancestor "$before" "$upstream"; then
            die "Your copy and the server's have both changed, so it can't be brought in as a plain update. Look at: git -C $WREN_DIR status"
        else
            # (as plain text: what the server sends could steer the terminal otherwise)
            g --no-pager log --stat --format='%n%h %s%n%b' 'HEAD..@{upstream}' | printable
            echo
            note "Every changed line: git -C $WREN_DIR diff HEAD..@{upstream}"
            # Only what Wren Linux's own key signed (pins.sh), checked before the merge
            if [ -n "${WREN_SIGNING_FPR:-}" ]; then
                update_signed "$upstream" ||
                    die "The newest commit on the server isn't signed by Wren Linux's key ($WREN_SIGNING_FPR), so nothing was brought in. Someone may have changed what the server gives you: don't bring it in by hand either."
                note "The newest commit is signed by Wren Linux's key ($WREN_SIGNING_FPR); it vouches for everything above."
            else
                note "(Its signature is not checked: no signing key is pinned in pins.sh yet, so this is as the server gives it.)"
            fi
            ask "Bring these in?" n || { note "Nothing changed."; return 0; }
            g merge --ff-only --quiet "$upstream" ||
                die "Couldn't bring it in (your files in $WREN_DIR conflict with it?). Look at: git -C $WREN_DIR status"
        fi
    elif [ -z "$offline" ]; then
        note "Wren Linux is up to date with the server."
    fi
    now=$(g rev-parse HEAD)

    # Signing keys that expire or are renewed (says nothing when all are fine)
    apt_keys_refresh || true

    # Where this computer's setup was last brought up to
    local old=
    if [ -n "$since" ]; then
        old=$(g rev-parse --verify --quiet "$since^{commit}") || die "I don't know the commit \"$since\"."
    elif [ -r "$STATE" ] && g cat-file -e "$(cat "$STATE")^{commit}" 2>/dev/null; then
        old=$(cat "$STATE")
    else
        old=$before
    fi

    local script core
    if $everything; then
        while IFS= read -r script; do
            if update_installed "$script"; then offered+=("$script"); fi
        done < <(update_scripts)
    elif [ "$old" != "$now" ]; then
        while IFS= read -r script; do
            if update_installed "$script"; then offered+=("$script"); fi
        done < <(update_affected "$old" "$now")
        # a base part that is new in this update (it can't be "set up" yet)
        while IFS= read -r script; do
            case " ${offered[*]} " in *" $script "*) ;; *) offered+=("$script") ;; esac
        done < <(update_new_parts "$old" "$now")
        core=$(update_core_changes "$old" "$now" | paste -sd' ')
        if [ -n "$core" ]; then
            note "Shared files changed too ($core). They aren't counted against any one script;"
            note "if something looks off, run the script for it again, or: bash update.sh --all"
        fi
    fi

    # and those whose installed commands are older than this copy
    if ! $everything; then
        while IFS= read -r script; do
            case " ${offered[*]} " in *" $script "*) ;; *) offered+=("$script") ;; esac
        done < <(update_stale)
    fi

    run_offered
}

# retired_offers: retired extras are not run again (out of offered); those
# still set up here are offered for removal, each asked on its own, into removing
retired_offers() {
    local script why kept=()
    for script in "${offered[@]}"; do
        [ -n "$(update_retired "$script")" ] || kept+=("$script")
    done
    offered=("${kept[@]}")
    while IFS= read -r script; do
        [[ $script == extras/* ]] || continue
        why=$(update_retired "$script")
        [ -n "$why" ] && update_installed "$script" || continue
        say "Wren Linux retired ${script#extras/}"
        note "$why"
        if ask "Take it out now (bash $script --remove)?" n; then
            removing+=("$script")
        else
            note "Kept. It gets no more updates; update.sh asks again next time."
        fi
    done < <(update_scripts)
}

# run_offered: offers the scripts in offered, runs them one after the other if
# you say so (and the --remove of those in removing), and on success records
# that the setup is up to date (save_state)
run_offered() {
    local script declined=false
    retired_offers
    if [ ${#offered[@]} -eq 0 ] && [ ${#removing[@]} -eq 0 ]; then
        save_state "$now"
        say "Nothing to run again."
        return 0
    fi

    if [ ${#offered[@]} -gt 0 ]; then
        say "These are set up here and use files that changed:"
        for script in "${offered[@]}"; do note "$script"; done
        ask "Run them again now, one after the other?" y || {
            note "Left for next time: run  bash update.sh  again (or run them yourself: bash <script>)."
            offered=() declined=true
            [ ${#removing[@]} -gt 0 ] || return 0
        }
    fi
    # The password once, here: a wrong one stops everything before any script
    # runs (and does not move on to ask again for the next), and the permission
    # is kept fresh while they run so there is no second question part way.
    if ! sudo_keepalive; then
        warn "sudo didn't accept the password, so nothing was run."
        warn "Run  bash update.sh  again when you are ready; the new files are already in place."
        return 1
    fi

    local failed=()
    for script in "${removing[@]}"; do
        say "Taking out ${script#extras/}"
        if ! "$RUNNER" "$WREN_DIR/$script" --remove; then
            failed+=("$script --remove")
            warn "$script --remove did not finish."
        fi
    done
    for script in "${offered[@]}"; do
        say "Running $script"
        if ! "$RUNNER" "$WREN_DIR/$script"; then
            failed+=("$script")
            warn "$script did not finish."
        fi
    done
    if [ ${#failed[@]} -gt 0 ]; then
        warn "These failed: ${failed[*]}. Run one again with: bash <script>. (update.sh will offer them again.)"
        return 1
    fi
    # (what you left for next time is offered again then)
    $declined && return 0
    save_state "$now"
    say "Up to date."
}

# save_state [COMMIT]: this computer's setup now matches COMMIT (a git copy),
# and every file as it is now (the record a package's copy is compared with)
save_state() {
    mkdir -p "$STATE_DIR"
    [ -z "${1:-}" ] || printf '%s\n' "$1" >"$STATE"
    update_manifest_save "$STATE_DIR"
}

# package_offers ALL: for the Wren Linux package's copy, which has no git: the
# scripts set up here whose files changed since the last run (the record
# save_state keeps), or all set up with ALL=true; into offered
package_offers() {
    local script
    note "Wren Linux is installed here as the Wren Linux package ($WREN_DIR): apt brings what is new"
    note "(unattended-upgrades every day, or: sudo apt update && sudo apt upgrade). This runs again what it changed."
    apt_keys_refresh || true
    if $1; then
        while IFS= read -r script; do
            if update_installed "$script"; then offered+=("$script"); fi
        done < <(update_scripts)
        return 0
    fi
    if [ -r "$STATE_DIR/applied-files" ] && [ -r "$STATE_DIR/applied-pins.sh" ]; then
        while IFS= read -r script; do
            if update_installed "$script"; then offered+=("$script"); fi
        done < <(update_affected_by "$(update_manifest_changed "$STATE_DIR")" "$(update_manifest_pin_vars "$STATE_DIR")")
        while IFS= read -r script; do
            case " ${offered[*]} " in *" $script "*) ;; *) offered+=("$script") ;; esac
        done < <(update_manifest_new_parts "$STATE_DIR")
    else
        note "The first run from the package: what is set up now counts as up to date (bash update.sh --all offers all of it)."
    fi
    while IFS= read -r script; do
        case " ${offered[*]} " in *" $script "*) ;; *) offered+=("$script") ;; esac
    done < <(update_stale)
}

offered=() removing=() now=
# Everything runs inside main: git may replace this very file while it runs
main "$@"
rc=$?
pause_if_asked
exit $rc