#!/usr/bin/env bash set -Eeuo pipefail APP_NAME="pi-car-companion" INSTALL_ROOT="/opt/${APP_NAME}" REPOSITORY="${INSTALL_ROOT}/repository" RELEASES="${INSTALL_ROOT}/releases" CURRENT="${INSTALL_ROOT}/current" STAGING_ROOT="${INSTALL_ROOT}/staging" CONFIG_DIR="/etc/${APP_NAME}" DATA_DIR="/var/lib/${APP_NAME}" STATUS_FILE="${DATA_DIR}/update-status.json" BRANCH_FILE="${CONFIG_DIR}/update-branch" UPDATE_USER_FILE="${CONFIG_DIR}/update-user" LOCK_FILE="/run/lock/${APP_NAME}-update.lock" WORKTREE="" RELEASE_TMP="" FROM_REVISION="" TO_REVISION="" PHASE="checking for updates" CHECK_ONLY=false if [[ "${1:-}" == "--check-only" ]]; then CHECK_ONLY=true elif [[ $# -gt 0 ]]; then echo "Usage: pi-car-companion-update [--check-only]" >&2 exit 2 fi if [[ ${EUID} -ne 0 ]]; then echo "The update helper must run as root." >&2 exit 1 fi write_status() { local state="$1" local message="$2" local update_group_id update_group_id="$(getent group pi-companion | cut -d: -f3)" UPDATE_STATE="${state}" \ UPDATE_MESSAGE="${message}" \ UPDATE_FROM="${FROM_REVISION}" \ UPDATE_TO="${TO_REVISION}" \ UPDATE_STATUS_FILE="${STATUS_FILE}" \ UPDATE_GROUP_ID="${update_group_id}" \ /usr/bin/node --input-type=module -e ' import { chmodSync, chownSync, renameSync, writeFileSync } from "node:fs"; const target = process.env.UPDATE_STATUS_FILE; const temporary = `${target}.tmp`; const value = { state: process.env.UPDATE_STATE, message: process.env.UPDATE_MESSAGE, fromRevision: process.env.UPDATE_FROM || null, toRevision: process.env.UPDATE_TO || null, updatedAt: new Date().toISOString() }; writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o644 }); chownSync(temporary, 0, Number(process.env.UPDATE_GROUP_ID)); chmodSync(temporary, 0o644); renameSync(temporary, target); ' } install_integration_files() { local source_release="$1" install -m 0755 "${source_release}/scripts/pi-car-companion-update" /usr/local/sbin/pi-car-companion-update install -m 0755 "${source_release}/scripts/pi-car-companion-network-setup" /usr/local/sbin/pi-car-companion-network-setup install -m 0755 "${source_release}/scripts/pi-car-companion-power" /usr/local/sbin/pi-car-companion-power install -m 0644 "${source_release}/systemd/pi-car-companion.service" /etc/systemd/system/pi-car-companion.service install -m 0644 "${source_release}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service install -m 0644 "${source_release}/systemd/pi-car-companion-update-check.service" /etc/systemd/system/pi-car-companion-update-check.service install -m 0644 "${source_release}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service install -m 0644 "${source_release}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service install -m 0644 "${source_release}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service install -m 0440 "${source_release}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion install -d -m 0755 /etc/NetworkManager/dnsmasq-shared.d install -m 0644 "${source_release}/config/dnsmasq-shared-pi-car-companion.conf" /etc/NetworkManager/dnsmasq-shared.d/pi-car-companion.conf } cleanup() { if [[ -n "${WORKTREE}" && "${WORKTREE}" == "${STAGING_ROOT}/"* && -e "${WORKTREE}" ]]; then git -C "${REPOSITORY}" worktree remove --force "${WORKTREE}" >/dev/null 2>&1 || rm -rf -- "${WORKTREE}" fi if [[ -n "${RELEASE_TMP}" && "${RELEASE_TMP}" == "${RELEASES}/.new-"* && -e "${RELEASE_TMP}" ]]; then rm -rf -- "${RELEASE_TMP}" fi } on_error() { local exit_code=$? trap - ERR cleanup write_status "failed" "Update failed while ${PHASE}. The previous version is still active." exit "${exit_code}" } trap on_error ERR trap cleanup EXIT exec 9>"${LOCK_FILE}" if ! flock -n 9; then write_status "failed" "Another update is already running." exit 1 fi if [[ ! -d "${REPOSITORY}/.git" || ! -f "${BRANCH_FILE}" || ! -f "${UPDATE_USER_FILE}" ]]; then write_status "failed" "The deployment repository is not configured. Run install.sh again." exit 1 fi FROM_REVISION="$(cat "${CURRENT}/REVISION" 2>/dev/null || true)" branch="$(tr -d '\r\n' < "${BRANCH_FILE}")" update_user="$(tr -d '\r\n' < "${UPDATE_USER_FILE}")" if ! id "${update_user}" >/dev/null 2>&1; then write_status "failed" "The Git update user no longer exists. Run install.sh again." exit 1 fi write_status "checking" "Checking the configured Git remote for updates." chown -R "${update_user}":"$(id -gn "${update_user}")" "${REPOSITORY}/.git" if [[ "${update_user}" == "root" ]]; then git -C "${REPOSITORY}" fetch --prune origin else sudo -H -u "${update_user}" -- git -C "${REPOSITORY}" fetch --prune origin fi TO_REVISION="$(git -C "${REPOSITORY}" rev-parse "origin/${branch}")" if [[ -n "${FROM_REVISION}" && "${FROM_REVISION}" == "${TO_REVISION}" ]]; then if [[ "${CHECK_ONLY}" != "true" ]]; then install_integration_files "${CURRENT}" visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null systemctl daemon-reload fi write_status "current" "Pi Car Companion is already up to date." exit 0 fi if [[ -n "${FROM_REVISION}" ]] && ! git -C "${REPOSITORY}" merge-base --is-ancestor "${FROM_REVISION}" "${TO_REVISION}"; then write_status "failed" "The remote update is not a fast-forward change. Automatic update was refused." exit 1 fi if [[ "${CHECK_ONLY}" == "true" ]]; then write_status "available" "A newer Pi Car Companion release is available to install." exit 0 fi PHASE="building the new release" write_status "building" "Downloading dependencies and verifying the new release." install -d -m 0755 "${STAGING_ROOT}" "${RELEASES}" WORKTREE="${STAGING_ROOT}/worktree-${TO_REVISION}-$$" git -C "${REPOSITORY}" worktree add --detach "${WORKTREE}" "${TO_REVISION}" cd "${WORKTREE}" npm ci npm run check printf '%s\n' "${TO_REVISION}" > REVISION PHASE="activating the new release" RELEASE_TMP="${RELEASES}/.new-${TO_REVISION}-$$" install -d -m 0755 "${RELEASE_TMP}" rsync -a --delete --exclude .git "${WORKTREE}/" "${RELEASE_TMP}/" release_final="${RELEASES}/${TO_REVISION}" if [[ -e "${release_final}" ]]; then rm -rf -- "${release_final}" fi mv "${RELEASE_TMP}" "${release_final}" RELEASE_TMP="" chown -R root:root "${release_final}" install_integration_files "${release_final}" visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null systemctl daemon-reload ln -sfn "releases/${TO_REVISION}" "${INSTALL_ROOT}/current.next" mv -Tf "${INSTALL_ROOT}/current.next" "${CURRENT}" PHASE="verifying the updated service" write_status "building" "The update is installed. Verifying the restarted service." healthy=false if systemctl restart pi-car-companion.service; then for _attempt in {1..30}; do if curl --fail --silent --show-error http://127.0.0.1:8787/healthz >/dev/null; then healthy=true break fi sleep 1 done fi if [[ "${healthy}" != "true" ]]; then if [[ -n "${FROM_REVISION}" && -d "${RELEASES}/${FROM_REVISION}" ]]; then install_integration_files "${RELEASES}/${FROM_REVISION}" systemctl daemon-reload ln -sfn "releases/${FROM_REVISION}" "${INSTALL_ROOT}/current.next" mv -Tf "${INSTALL_ROOT}/current.next" "${CURRENT}" systemctl restart pi-car-companion.service || true write_status "failed" "The new release failed its health check. The previous release was restored." else write_status "failed" "The new release failed its health check and no previous release was available." fi exit 1 fi write_status "success" "Update installed and the companion service is healthy." systemctl try-restart pi-car-companion-network.service || true systemctl enable pi-car-companion-network-observer.service || true systemctl restart pi-car-companion-network-observer.service || true