rover self updating!

This commit is contained in:
legop3
2026-06-05 15:09:10 -04:00
parent 870de4f135
commit c097d94ba8
14 changed files with 359 additions and 132 deletions
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# Fixed-purpose self-update helper for Raspberry Pi rover hosts.
#
# roverd itself runs as the unprivileged "roverd" service user, but updating the
# installed agent requires root because the installer writes to /usr/local/bin,
# /etc, /opt, /var/lib/roverd, and systemd unit locations. This helper is the
# narrow privilege boundary: sudoers allows roverd to run this exact file with no
# arguments, and this file decides the complete update sequence internally.
set -euo pipefail
ENV_FILE="/etc/roverd-update.env"
LOCK_FILE="/var/lock/roverd-self-update.lock"
LOG_FILE="/var/log/roverd-self-update.log"
log() {
# Log to stdout so systemd/journal captures the message when launched by
# roverd, and also append to a stable file so an admin can inspect the last
# update attempt after the roverd service restarts.
local message
message="[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
echo "$message"
echo "$message" >> "$LOG_FILE"
}
if [[ "${EUID}" -ne 0 ]]; then
echo "roverd-self-update must run as root" >&2
exit 1
fi
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing $ENV_FILE; run pi/install_roverd.sh once to register the repository path" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
if [[ -z "${ROVERD_REPO_DIR:-}" ]]; then
echo "ROVERD_REPO_DIR is not set in $ENV_FILE" >&2
exit 1
fi
if [[ ! -d "$ROVERD_REPO_DIR/.git" ]]; then
echo "ROVERD_REPO_DIR does not point at a git checkout: $ROVERD_REPO_DIR" >&2
exit 1
fi
if [[ ! -x "$ROVERD_REPO_DIR/pi/install_roverd.sh" ]]; then
echo "Installer is missing or not executable: $ROVERD_REPO_DIR/pi/install_roverd.sh" >&2
exit 1
fi
mkdir -p "$(dirname "$LOCK_FILE")" "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
chmod 0644 "$LOG_FILE"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "Another roverd self-update is already running; refusing to start a second one"
exit 1
fi
cd "$ROVERD_REPO_DIR"
log "Starting roverd self-update in $ROVERD_REPO_DIR"
repo_uid="$(stat -c '%u' "$ROVERD_REPO_DIR")"
# Fetch through the normal git remote instead of downloading just one artifact.
# That keeps the binary, installer, scripts, systemd units, and any other rover
# files from this repository on the same commit.
if [[ "$repo_uid" -eq 0 ]]; then
git pull --ff-only
else
# The helper runs as root only for installation privileges. The git checkout
# usually belongs to the human deploy user, so running the pull as that owner
# avoids Git's dubious-ownership protection and preserves whatever SSH/HTTPS
# credentials that user normally uses for this repository.
sudo -H -u "#${repo_uid}" git -C "$ROVERD_REPO_DIR" pull --ff-only
fi
log "Repository fast-forward pull complete"
# The installer already owns the full desired rover host state. Reusing it here
# prevents this update helper from becoming a second, partial installer that can
# drift away from the normal manual install path.
"$ROVERD_REPO_DIR/pi/install_roverd.sh"
log "Installer completed successfully"
+41
View File
@@ -6,6 +6,7 @@ set -euo pipefail
BINARY_SRC="dist/roverd"
CONFIG_SRC="pi/roverd/roverd.sample.yaml"
REPO_ROOT="$(pwd -P)"
usage() {
cat <<'USAGE'
@@ -22,6 +23,7 @@ The script must run from the repository root and as root (sudo). It will:
* install /usr/local/bin/roverd and /etc/roverd.yaml
* install /usr/local/bin/video/audio helpers and systemd units
* install fixed-location Google Chrome TTS assets for roverd
* install the fixed-command self-update helper used by admin-triggered updates
* enable roverd.service and media publisher/listener services
USAGE
}
@@ -131,6 +133,45 @@ ensure_user roverd "dialout,gpio,video,render,audio"
install -o roverd -g roverd -m 0755 "$BINARY_SRC" /usr/local/bin/roverd
log "Installed roverd binary"
install_self_update_support() {
local sudoers_file="/etc/sudoers.d/roverd-self-update"
local update_env="/etc/roverd-update.env"
local quoted_repo_root
# The self-update helper must know which checkout should receive the git
# pull. Recording the repository root during the normal installer run keeps
# the runtime websocket command simple and prevents the rover from accepting
# a caller-controlled path.
printf -v quoted_repo_root '%q' "$REPO_ROOT"
install -D -o root -g root -m 0644 /dev/null "$update_env"
cat > "$update_env" <<ENV
# Managed by pi/install_roverd.sh.
# This path is intentionally captured from the installer working directory so
# admin-triggered rover updates always operate on the same full repository that
# was used for the manual install.
ROVERD_REPO_DIR=$quoted_repo_root
ENV
log "Registered roverd update repository at $REPO_ROOT"
# The helper is root-owned and argument-free. sudoers grants the roverd
# service user exactly this command and nothing broader, which is important
# because update requests arrive over the rover websocket.
install -D -o root -g root -m 0755 pi/bin/roverd-self-update.sh /usr/local/sbin/roverd-self-update
cat > "$sudoers_file" <<'SUDOERS'
# Managed by pi/install_roverd.sh.
# Allow only the roverd service account to run the fixed self-update helper.
roverd ALL=(root) NOPASSWD: /usr/local/sbin/roverd-self-update
SUDOERS
chown root:root "$sudoers_file"
chmod 0440 "$sudoers_file"
if command -v visudo >/dev/null 2>&1; then
visudo -cf "$sudoers_file" >/dev/null
fi
log "Installed roverd self-update helper and sudoers rule"
}
install_self_update_support
CONFIG_DEST="/etc/roverd.yaml"
CONFIG_EXISTS=0
if [[ -f "$CONFIG_DEST" ]]; then
+6
View File
@@ -45,6 +45,10 @@ type inboundMessage struct {
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
Song *songPayload `json:"song,omitempty"`
Reboot *rebootPayload `json:"reboot,omitempty"`
// Update is intentionally just a marker payload. The server can request the
// fixed self-update workflow, but it cannot pass paths, commands, branches,
// or installer flags through the websocket into the privileged helper.
Update *updatePayload `json:"update,omitempty"`
}
type driveDirectPayload struct {
@@ -112,6 +116,8 @@ type rebootPayload struct {
DelayMs int `json:"delayMs,omitempty"`
}
type updatePayload struct{}
type ackMessage struct {
Type string `json:"type"`
ID string `json:"id"`
+54 -4
View File
@@ -35,6 +35,7 @@ type WSClient struct {
rebootT *time.Timer
seekIssued bool
rebootIssued bool
updateIssued bool
audioLevels AudioLevels
audioMu sync.RWMutex
}
@@ -244,20 +245,32 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return c.adapter.PlaySong(slot, msg.Song.Notes)
case msg.Reboot != nil || msg.Type == "reboot":
return c.handleRebootCommand(msg.Reboot)
case msg.Update != nil || msg.Type == "update":
return c.handleUpdateCommand()
default:
return fmt.Errorf("unsupported command type: %s", msg.Type)
}
}
func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
func (c *WSClient) stopMotionForSystemCommand(reason string) error {
// System-level commands can restart the process or the whole Pi. Stopping
// both wheel and auxiliary motors first leaves the Roomba in a predictable
// state before roverd hands control to systemd or the update helper.
if err := c.adapter.DriveDirect(0, 0); err != nil {
return fmt.Errorf("stop drive before reboot: %w", err)
return fmt.Errorf("stop drive before %s: %w", reason, err)
}
if err := c.adapter.MotorPWM(0, 0, 0); err != nil {
return fmt.Errorf("stop aux motors before reboot: %w", err)
return fmt.Errorf("stop aux motors before %s: %w", reason, err)
}
if err := c.adapter.StartOI(); err != nil {
return fmt.Errorf("enter passive mode before reboot: %w", err)
return fmt.Errorf("enter passive mode before %s: %w", reason, err)
}
return nil
}
func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
if err := c.stopMotionForSystemCommand("reboot"); err != nil {
return err
}
delay := 300 * time.Millisecond
@@ -290,6 +303,42 @@ func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
return nil
}
func (c *WSClient) handleUpdateCommand() error {
if err := c.stopMotionForSystemCommand("self-update"); err != nil {
return err
}
c.connMu.Lock()
if c.updateIssued {
c.connMu.Unlock()
return fmt.Errorf("update already pending")
}
c.updateIssued = true
c.connMu.Unlock()
c.emitEvent("system.updateStarting", map[string]any{
"source": "remoteCommand",
})
// The helper is launched asynchronously because a successful update may
// restart roverd before this websocket command could stream progress back to
// the server. sudo is intentionally limited by /etc/sudoers.d/roverd-self-update
// to one root-owned helper with no caller-controlled arguments.
cmd := exec.Command("sudo", "-n", "/usr/local/sbin/roverd-self-update")
if err := cmd.Start(); err != nil {
c.connMu.Lock()
c.updateIssued = false
c.connMu.Unlock()
return fmt.Errorf("start self-update helper: %w", err)
}
if err := cmd.Process.Release(); err != nil {
c.log.Printf("release self-update helper process handle failed: %v", err)
}
c.log.Printf("started roverd self-update helper with pid %d", cmd.Process.Pid)
return nil
}
func (c *WSClient) applyAutoSideBrush(left, right int) {
if c.cfg == nil || !c.cfg.AutoSideBrush.Enabled {
if c.autoSideOn {
@@ -576,6 +625,7 @@ func (c *WSClient) markConnected() {
c.connected = true
c.seekIssued = false
c.rebootIssued = false
c.updateIssued = false
if c.disconnectT != nil {
c.disconnectT.Stop()
c.disconnectT = nil