feat(install): updates refresh systemd units; new installs run the newest release (#729)

Updates that move HEAD now install changed systemd units through a root-owned helper (/usr/local/sbin/ledmatrix-refresh-units, two literal sudo lines), with a backup restored on rollback; a refresh that fails part-way puts the old units back. Devices without the new sudo rule keep updating and are told to re-run the installer once. The one-shot installer now checks out the newest vX.Y.Z release (LEDMATRIX_CHANNEL=beta keeps main) and never moves an existing checkout backwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-03 13:09:53 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 515248b34e
commit f841fa36b6
24 changed files with 1974 additions and 29 deletions
+10 -3
View File
@@ -5,11 +5,18 @@ This directory contains scripts for installing and configuring the LEDMatrix sys
## Scripts
- **`one-shot-install.sh`** - Single-command installer; clones the
repo, checks prerequisites, then runs `first_time_install.sh`.
Invoked via `curl ... | bash` from the project root README.
repo, checks out the newest release (or `main` with
`LEDMATRIX_CHANNEL=beta`), checks prerequisites, then runs
`first_time_install.sh`. Invoked via `curl ... | bash` from the project
root README. Re-running it never moves a checkout to an older version.
- **`install_service.sh`** - Installs, enables and starts the display
service (`ledmatrix.service`), the web interface service
(`ledmatrix-web.service`) and the update-verify units (systemd)
(`ledmatrix-web.service`) and the update-verify units (systemd), and
installs `/usr/local/sbin/ledmatrix-refresh-units`
- **`ledmatrix_refresh_units.py`** - Not run from here: `install_service.sh`
installs a root-owned copy as `/usr/local/sbin/ledmatrix-refresh-units`,
which updates run through sudo to install changed units (and the
automatic update's rollback, with `--restore`, to put them back)
- **`install_web_service.sh`** - Installs only the web interface service
and the update-verify units (systemd)
- **`install_wifi_monitor.sh`** - Installs the WiFi monitor daemon service
+2
View File
@@ -138,6 +138,8 @@ echo "- View system logs via journalctl"
echo "- Reboot and shutdown the system"
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
echo "- Install plugin/base requirements.txt as root (so ledmatrix.service can see them)"
echo "- Install the LEDMatrix systemd units an update changed, and restore them on rollback"
echo " (/usr/local/sbin/ledmatrix-refresh-units, installed by install_service.sh)"
echo ""
# Ask for confirmation
+24
View File
@@ -143,6 +143,30 @@ for VERIFY_UNIT in ledmatrix-update-verify.service ledmatrix-update-verify.path;
fi
done
# The helper updates run (through sudo, see lib_sudoers.sh) to install these
# same units when a new version changes their templates, and to put the old
# ones back if the automatic update rolls back. Root-owned and outside the
# checkout, so the web user who owns the checkout cannot change what sudo runs.
# Not fatal: without it, updates leave the units for the next reinstall.
REFRESH_UNITS_SRC="$PROJECT_ROOT_DIR/scripts/install/ledmatrix_refresh_units.py"
REFRESH_UNITS_DEST=/usr/local/sbin/ledmatrix-refresh-units
if [ -f "$REFRESH_UNITS_SRC" ]; then
if sudo install -D -o root -g root -m 0755 "$REFRESH_UNITS_SRC" "$REFRESH_UNITS_DEST"; then
echo "Installed $REFRESH_UNITS_DEST (lets updates refresh these units)"
else
echo "WARNING: could not install $REFRESH_UNITS_DEST; updates will not refresh the systemd units" >&2
fi
fi
# The units above are copied from mktemp files, which are 0600. 0644 is what
# first_time_install.sh (Step 8.1) sets, and lets the web interface compare
# them with the templates after an update without root.
for INSTALLED_UNIT in ledmatrix.service ledmatrix-web.service \
ledmatrix-update-verify.service ledmatrix-update-verify.path; do
if [ -f "/etc/systemd/system/$INSTALLED_UNIT" ]; then
sudo chmod 644 "/etc/systemd/system/$INSTALLED_UNIT" || true
fi
done
echo "Reloading systemd daemon for web service..."
sudo systemctl daemon-reload
+451
View File
@@ -0,0 +1,451 @@
#!/usr/bin/python3 -I
"""Refresh the installed LEDMatrix systemd units from the checkout's templates.
Installed by scripts/install/install_service.sh as a root-owned copy,
/usr/local/sbin/ledmatrix-refresh-units, and granted to the web interface's
user by /etc/sudoers.d/ledmatrix_web (scripts/install/lib_sudoers.sh) with
exactly two command lines:
ledmatrix-refresh-units (no arguments)
ledmatrix-refresh-units --restore
An update (Update Code, or the weekly automatic update) pulls new unit
templates into systemd/, but the units systemd runs are the copies in
/etc/systemd/system, which only the installer used to write. So a setting
added to a template -- the render-loop watchdog, a memory limit -- never
reached a device that was already installed. After an update the web
interface runs this, and the next restart picks the new units up.
* **No arguments:** render each installed unit from systemd/<unit> exactly as
install_service.sh does (__PROJECT_ROOT_DIR__ and __USER__ replaced
literally), and install the ones whose content differs (comments and blank
lines aside, as src/startup_validator.py compares them), then
``systemctl daemon-reload``. The units replaced are saved first, so the
automatic update's rollback can put them back.
* ``--restore``: put back the units the last refresh replaced, and
daemon-reload. Nothing saved means nothing to do.
* ``--check``: print the units that would change, one per line. Needs no
root and changes nothing.
What it trusts, and why. It takes no other input: the project directory and
the web interface's user come from the installed, root-owned
ledmatrix.service and ledmatrix-web.service, not from the caller, and sudo
strips the caller's environment (``-I`` ignores the PYTHON* variables too).
It only replaces units that are already installed, only the four
install_service.sh installs, and only with a rendering that keeps each unit's
User= (root for the display, the web user for the others) and
WorkingDirectory=. The templates are files the web user can edit -- but so is
run.py, which ledmatrix.service already runs as root, so a template grants
nothing that user did not have; the checks keep a damaged or hostile template
from changing who a unit runs as, and keep this from reading anything but a
regular file under the checkout's systemd/ folder.
Standard library only, and no imports from the checkout: the installed copy
must not run code the web user can change.
"""
import json
import os
import re
import stat
import subprocess # nosec B404 - fixed argv, no shell # nosemgrep
import sys
import tempfile
SYSTEMD_DIR = '/etc/systemd/system'
#: Root-only: the units the last refresh replaced, for --restore.
BACKUP_DIR = '/var/lib/ledmatrix/unit-backup'
MANIFEST = 'manifest.json'
INSTALLED_PATH = '/usr/local/sbin/ledmatrix-refresh-units'
DISPLAY_UNIT = 'ledmatrix.service'
WEB_UNIT = 'ledmatrix-web.service'
VERIFY_SERVICE = 'ledmatrix-update-verify.service'
VERIFY_PATH = 'ledmatrix-update-verify.path'
#: What install_service.sh installs, in its order. Nothing else is touched.
UNITS = (DISPLAY_UNIT, WEB_UNIT, VERIFY_SERVICE, VERIFY_PATH)
MAX_TEMPLATE_BYTES = 64 * 1024
_USER_RE = re.compile(r'^[a-z_][a-z0-9_-]{0,31}$')
#: systemd expands % specifiers, and a quote, backslash or line break would
#: be reinterpreted in a unit file (src/auto_update_setup.py refuses the same).
#: (On Windows, where the tests also run, a backslash is the path separator.)
_UNSAFE_PATH_CHARS = set('%"') | ({'\\'} if os.sep == '/' else set())
EXIT_OK = 0
EXIT_FAILED = 1
EXIT_USAGE = 2
class RefreshError(Exception):
"""Why the units were left alone, in words for the web interface's log."""
class UnitsUnreadable(RefreshError):
"""An installed unit is not readable by this (unprivileged) user.
install_service.sh used to leave units mode 0600 (first_time_install.sh's
Step 8.1 makes them 0644), so ``--check`` as the web user cannot always
tell; the root helper itself can.
"""
def directive_values(text, key):
"""Every value of ``key=`` in a unit's text, in order (systemd allows spaces around ``=``)."""
return [m.group(1).strip() for m in re.finditer(rf'^[ \t]*{key}[ \t]*=(.*)$', text or '', re.M)]
def layout_problem(text, section, keys):
"""What would make ``directive_values`` misread the unit as systemd reads it, or None.
A ``User=`` inside a backslash-continued line is part of the line before,
and one under [Unit] is ignored, so either could pass a check that systemd
then does not apply. Neither appears in the shipped templates.
"""
current = None
for raw in (text or '').splitlines():
line = raw.strip()
if not line or line.startswith(('#', ';')):
continue
if line.endswith('\\'):
return 'continues a line with a backslash'
if line.startswith('[') and line.endswith(']'):
current = line[1:-1]
continue
key = line.split('=', 1)[0].strip()
if key in keys and current != section:
return f'sets {key}= outside [{section}]'
return None
def unit_body(text):
"""A unit's meaningful lines in order: no comments, no blank lines.
The same comparison src/startup_validator.py uses for its drift warning,
so what this refreshes is exactly what that warns about.
"""
lines = []
for line in (text or '').splitlines():
line = line.strip()
if line and not line.startswith('#'):
lines.append(line)
return '\n'.join(lines)
def render(template, project_root, user):
"""install_service.sh's ``sed "s|__PROJECT_ROOT_DIR__|...|g; s|__USER__|...|g"``."""
return template.replace('__PROJECT_ROOT_DIR__', project_root).replace('__USER__', user)
def _read_regular(path, limit=MAX_TEMPLATE_BYTES, dir_fd=None):
"""A regular file's text, never through a symlink, a FIFO or a device."""
flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
kwargs = {'dir_fd': dir_fd} if dir_fd is not None else {}
fd = os.open(path, flags, **kwargs)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode):
raise RefreshError(f'{path} is not a regular file')
if info.st_size > limit:
raise RefreshError(f'{path} is larger than {limit} bytes')
data = b''
while True:
chunk = os.read(fd, limit + 1 - len(data))
if not chunk:
break
data += chunk
if len(data) > limit:
raise RefreshError(f'{path} is larger than {limit} bytes')
finally:
os.close(fd)
if b'\0' in data:
raise RefreshError(f'{path} is not a text file')
try:
return data.decode('utf-8')
except UnicodeDecodeError as e:
raise RefreshError(f'{path} is not UTF-8') from e
def _read_installed(systemd_dir, name):
path = os.path.join(systemd_dir, name)
try:
with open(path, 'r', encoding='utf-8') as f:
return f.read()
except FileNotFoundError:
return None
except PermissionError as e:
raise UnitsUnreadable(f'cannot read the installed {name}: {e}') from e
except (OSError, UnicodeDecodeError) as e:
raise RefreshError(f'cannot read the installed {name}: {e}') from e
def _lookup_user(user):
try:
import pwd
except ImportError: # not a POSIX host (the tests on Windows)
return True
try:
pwd.getpwnam(user)
return True
except KeyError:
return False
class Refresher:
def __init__(self, systemd_dir=SYSTEMD_DIR, backup_dir=BACKUP_DIR, run=subprocess.run,
is_root=None, user_exists=_lookup_user, log=None):
self.systemd_dir = systemd_dir
self.backup_dir = backup_dir
self.run = run
self.is_root = is_root or (lambda: hasattr(os, 'geteuid') and os.geteuid() == 0)
self.user_exists = user_exists
self.log = log or (lambda msg: print(msg, flush=True))
# -- what the installed units say -------------------------------------
def context(self, installed):
"""(project root, web user) from the installed, root-owned units."""
display = installed.get(DISPLAY_UNIT)
if display is None:
raise RefreshError(f'{DISPLAY_UNIT} is not installed; run scripts/install/install_service.sh')
roots = directive_values(display, 'WorkingDirectory')
if len(roots) != 1:
raise RefreshError(f'the installed {DISPLAY_UNIT} does not name one WorkingDirectory')
root = roots[0]
if (not os.path.isabs(root) or any(ch in _UNSAFE_PATH_CHARS or ord(ch) < 32 for ch in root)
or os.path.normpath(root) != root):
raise RefreshError(f'the installed {DISPLAY_UNIT} runs from {root!r}, which cannot be used')
if not os.path.isdir(root):
raise RefreshError(f'{root} (the installed {DISPLAY_UNIT} WorkingDirectory) does not exist')
user = None
web = installed.get(WEB_UNIT)
if web is not None:
users = directive_values(web, 'User')
user = users[0] if len(users) == 1 else ('root' if not users else None)
if user is None or not _USER_RE.match(user) or not self.user_exists(user):
raise RefreshError(f'the installed {WEB_UNIT} runs as an account that cannot be used')
if directive_values(web, 'WorkingDirectory') != [root]:
raise RefreshError(f'the installed {WEB_UNIT} and {DISPLAY_UNIT} run from different folders')
return root, user
@staticmethod
def expected_user(name, web_user):
return 'root' if name == DISPLAY_UNIT else web_user
def _template(self, root, name):
"""systemd/<name> under the checkout, as a regular file, never via a symlink."""
dir_flags = os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0)
if os.open in getattr(os, 'supports_dir_fd', set()):
try:
dfd = os.open(os.path.join(root, 'systemd'), dir_flags)
except OSError as e:
raise RefreshError(f'cannot open {root}/systemd: {e}') from e
try:
return _read_regular(name, dir_fd=dfd)
except FileNotFoundError:
return None
except OSError as e:
raise RefreshError(f'cannot read systemd/{name}: {e}') from e
finally:
os.close(dfd)
path = os.path.join(root, 'systemd', name)
if os.path.islink(os.path.join(root, 'systemd')):
raise RefreshError(f'{root}/systemd is a symlink')
try:
return _read_regular(path)
except FileNotFoundError:
return None
except OSError as e:
raise RefreshError(f'cannot read systemd/{name}: {e}') from e
def _validate(self, name, rendered, root, user):
problem = layout_problem(rendered, 'Service', ('User', 'WorkingDirectory'))
if problem:
raise RefreshError(f'systemd/{name} {problem}; refusing to install it')
if directive_values(rendered, 'User') != [user]:
raise RefreshError(f'systemd/{name} would not run as {user}; refusing to install it')
if directive_values(rendered, 'WorkingDirectory') != [root]:
raise RefreshError(f'systemd/{name} would not run from {root}; refusing to install it')
def plan(self):
"""{unit: (installed text, new text)} for every installed unit that would change.
Raises RefreshError, and so changes nothing, if any unit cannot be
rendered safely: four units refreshed as a set or not at all.
"""
installed = {name: _read_installed(self.systemd_dir, name) for name in UNITS}
root, web_user = self.context(installed)
changes = {}
for name in UNITS:
current = installed[name]
if current is None:
continue # never installed here: installing is the installer's job
user = self.expected_user(name, web_user)
if user is None:
continue # the web unit is not installed, so neither is its user
template = self._template(root, name)
if template is None:
continue # a version without this unit leaves the installed one alone
rendered = render(template, root, user)
# A path unit runs nothing itself; what matters is what it starts.
if name.endswith('.service'):
self._validate(name, rendered, root, user)
else:
self._validate_path(name, rendered)
if unit_body(rendered) != unit_body(current):
changes[name] = (current, rendered)
return changes
def _validate_path(self, name, rendered):
problem = layout_problem(rendered, 'Path', ('Unit',))
if problem:
raise RefreshError(f'systemd/{name} {problem}; refusing to install it')
if directive_values(rendered, 'Unit') != [VERIFY_SERVICE]:
raise RefreshError(f'systemd/{name} does not start {VERIFY_SERVICE}; refusing to install it')
if directive_values(rendered, 'User'):
raise RefreshError(f'systemd/{name} sets User=; refusing to install it')
# -- writing ------------------------------------------------------------
def _write_unit(self, name, text):
fd, tmp = tempfile.mkstemp(dir=self.systemd_dir, prefix=f'.{name}.')
try:
with os.fdopen(fd, 'w', encoding='utf-8', newline='\n') as f:
f.write(text)
os.chmod(tmp, 0o644)
os.replace(tmp, os.path.join(self.systemd_dir, name))
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _backup_dir(self):
"""The backup folder, created root-only; refused if it is not a plain folder."""
os.makedirs(os.path.dirname(self.backup_dir), mode=0o755, exist_ok=True)
try:
os.mkdir(self.backup_dir, 0o700)
except FileExistsError:
pass
info = os.lstat(self.backup_dir)
if not stat.S_ISDIR(info.st_mode):
raise RefreshError(f'{self.backup_dir} is not a folder')
if hasattr(os, 'geteuid') and info.st_uid != os.geteuid():
raise RefreshError(f'{self.backup_dir} is not owned by root')
return self.backup_dir
def _clear_backup(self, folder):
for entry in os.listdir(folder):
path = os.path.join(folder, entry)
if os.path.isfile(path) or os.path.islink(path):
os.unlink(path)
def _systemctl(self, *args):
result = self.run(['systemctl', *args], capture_output=True, text=True, timeout=60)
if result.returncode != 0:
raise RefreshError(f'"systemctl {" ".join(args)}" failed: '
f'{(result.stderr or result.stdout or "").strip()}')
def _restart_path_unit_if_active(self, names):
"""A rewritten path unit watches the old path until it is restarted."""
if VERIFY_PATH not in names:
return
state = self.run(['systemctl', 'is-active', VERIFY_PATH], capture_output=True, text=True, timeout=30)
if (state.stdout or '').strip() == 'active':
self._systemctl('restart', VERIFY_PATH)
def refresh(self):
if not self.is_root():
raise RefreshError('must run as root (sudo)')
changes = self.plan()
folder = self._backup_dir()
# Always reset: the backup belongs to this refresh, so a --restore
# after an update that changed nothing restores nothing.
self._clear_backup(folder)
if not changes:
self.log('units: up to date')
return []
for name, (current, _) in changes.items():
with open(os.path.join(folder, name), 'w', encoding='utf-8', newline='\n') as f:
f.write(current)
with open(os.path.join(folder, MANIFEST), 'w', encoding='utf-8') as f:
json.dump({'units': sorted(changes)}, f)
try:
for name, (_, rendered) in changes.items():
self._write_unit(name, rendered)
self._systemctl('daemon-reload')
except BaseException:
# A failed refresh is reported as a failure, so the update records
# no units_refreshed and a rollback would not --restore. Put the
# replaced units back now, rather than leave a half-written set
# under the old code.
self._undo(changes, folder)
raise
self._restart_path_unit_if_active(changes)
self.log('units refreshed: ' + ' '.join(sorted(changes)))
return sorted(changes)
def _undo(self, changes, folder):
"""Best effort: reinstall the units a failed refresh replaced."""
undone = True
for name, (current, _) in changes.items():
try:
self._write_unit(name, current)
except OSError as e:
undone = False
self.log(f'units: could not put back {name}: {e}')
try:
self.run(['systemctl', 'daemon-reload'], capture_output=True, text=True, timeout=60)
except (OSError, subprocess.SubprocessError) as e:
self.log(f'units: daemon-reload after putting units back failed: {e}')
if undone:
# Nothing is left to restore; keep the backup only if a unit could
# not be put back, so a manual --restore still can.
self._clear_backup(folder)
def restore(self):
if not self.is_root():
raise RefreshError('must run as root (sudo)')
folder = self._backup_dir()
try:
manifest = json.loads(_read_regular(os.path.join(folder, MANIFEST)))
except FileNotFoundError:
self.log('units: nothing to restore')
return []
names = [n for n in (manifest or {}).get('units', []) if n in UNITS]
for name in names:
self._write_unit(name, _read_regular(os.path.join(folder, name)))
self._systemctl('daemon-reload')
self._restart_path_unit_if_active(names)
self._clear_backup(folder)
self.log('units restored: ' + ' '.join(names))
return names
def main(argv, refresher=None):
args = argv[1:]
if args not in ([], ['--restore'], ['--check']):
print('usage: ledmatrix-refresh-units [--restore | --check]', file=sys.stderr)
return EXIT_USAGE
refresher = refresher or Refresher()
try:
if args == ['--check']:
for name in sorted(refresher.plan()):
print(name)
elif args == ['--restore']:
refresher.restore()
else:
refresher.refresh()
except (RefreshError, OSError, subprocess.SubprocessError, ValueError) as e:
print(f'ledmatrix-refresh-units: {e}', file=sys.stderr)
return EXIT_FAILED
return EXIT_OK
if __name__ == '__main__':
# Only as the installed program: sudo already sets a secure PATH, and
# this pins the one systemctl comes from. (Not in main(), which the
# tests call in-process.)
os.environ['PATH'] = '/usr/sbin:/usr/bin:/sbin:/bin'
sys.exit(main(sys.argv))
+9
View File
@@ -10,6 +10,11 @@
#
# Add or remove a grant here and nowhere else.
# Root-owned copy of scripts/install/ledmatrix_refresh_units.py, installed by
# install_service.sh. Outside the checkout on purpose: the web user owns the
# checkout, so a granted file inside it could be rewritten and run as root.
LEDMATRIX_REFRESH_UNITS_PATH=/usr/local/sbin/ledmatrix-refresh-units
# web_sudoers_rules WEB_USER PROJECT_ROOT SYSTEMCTL_PATH BASH_PATH REBOOT_PATH POWEROFF_PATH JOURNALCTL_PATH
#
# Print the ledmatrix_web sudoers rules to stdout.
@@ -58,6 +63,10 @@ $WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pl
# Install a requirements.txt as root via vetted helper, so packages are visible
# to root-run ledmatrix.service (not just the web interface's own user).
$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *
# After an update, install the new systemd units (no arguments: "" allows none)
# and, on the automatic update's rollback, put the previous ones back.
$WEB_USER ALL=(ALL) NOPASSWD: $LEDMATRIX_REFRESH_UNITS_PATH ""
$WEB_USER ALL=(ALL) NOPASSWD: $LEDMATRIX_REFRESH_UNITS_PATH --restore
EOF
if [ -n "$JOURNALCTL_PATH" ]; then
cat << EOF
+119 -1
View File
@@ -3,6 +3,10 @@
# LED Matrix One-Shot Installation Script
# This script provides a single-command installation experience
# Usage: curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh | bash
#
# A new install runs the newest release (the stable update channel). For the
# newest code from main instead (the beta channel), set LEDMATRIX_CHANNEL=beta:
# curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh | LEDMATRIX_CHANNEL=beta bash
set -Eeuo pipefail
@@ -205,6 +209,114 @@ check_sudo() {
print_success "Sudo access confirmed"
}
# --- release checkout helpers ------------------------------------------------
# Which version an install runs. The rules are web_interface/update_channel.py's,
# so the installer and the web interface's updates agree:
# stable (default) the newest vX.Y.Z tag by semantic version; pre-releases
# (v3.8.0-rc1), leading zeros and other tags are ignored
# beta main, the newest code
# Never backwards: an existing checkout moves to a release only when that
# release contains its current commit (git merge-base --is-ancestor).
# Never fatal: whatever goes wrong, the install carries on with the checkout
# as it is.
# Print "stable" or "beta": LEDMATRIX_CHANNEL when it is set, else the
# existing install's auto_update.channel (CONFIG_FILE), else stable.
_lm_channel() {
local config_file="${1:-}" value
value=$(printf '%s' "${LEDMATRIX_CHANNEL:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
case "$value" in
stable|beta) printf '%s\n' "$value"; return 0 ;;
"") ;;
*) print_warning "LEDMATRIX_CHANNEL=${LEDMATRIX_CHANNEL} is not stable or beta; using stable" >&2
printf 'stable\n'; return 0 ;;
esac
if [ -n "$config_file" ] && [ -f "$config_file" ] && command -v python3 >/dev/null 2>&1; then
value=$(python3 - "$config_file" 2>/dev/null <<'PY' || true
import json, sys
try:
with open(sys.argv[1], encoding="utf-8") as f:
section = json.load(f).get("auto_update")
value = section.get("channel") if isinstance(section, dict) else None
print(value.strip().lower() if isinstance(value, str) else "")
except Exception:
print("")
PY
)
if [ "$value" = "beta" ]; then
printf 'beta\n'
return 0
fi
fi
printf 'stable\n'
}
# Print the newest release tag of the repository in the current directory,
# or nothing when it has none.
_lm_newest_release_tag() {
git tag --list 'v*' 2>/dev/null \
| grep -E '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \
| sort -t. -k1.2,1n -k2,2n -k3,3n \
| tail -n 1 || true
}
# A fresh clone (on main): move to the newest release unless beta was asked for.
_lm_checkout_release_after_clone() {
local channel tag
channel=$(_lm_channel "")
if [ "$channel" = "beta" ]; then
print_success "Beta channel: installing the newest code from main"
return 0
fi
tag=$(_lm_newest_release_tag)
if [ -z "$tag" ]; then
print_warning "No release found; installing the newest code from main"
return 0
fi
if git -c advice.detachedHead=false checkout --quiet --detach "${tag}^{commit}"; then
print_success "Installing release $tag (stable channel)"
else
print_warning "Could not check out release $tag; installing the newest code from main"
fi
return 0
}
# An existing checkout: move it forward along its channel, never backwards.
# Returns 1 when it should be updated the way it always was (a fast-forward
# pull of its branch): beta, or stable on a branch newer than every release.
_lm_update_existing_checkout() {
local channel tag head tag_sha
channel=$(_lm_channel "config/config.json")
if [ "$channel" = "beta" ]; then
return 1
fi
if ! git fetch --quiet --tags --force origin >/dev/null 2>&1; then
print_warning "Could not fetch release tags; keeping the current version"
return 0
fi
tag=$(_lm_newest_release_tag)
head=$(git rev-parse --verify --quiet HEAD 2>/dev/null || true)
if [ -n "$tag" ] && [ -n "$head" ] && git merge-base --is-ancestor "$head" "$tag" 2>/dev/null; then
tag_sha=$(git rev-parse --verify --quiet "${tag}^{commit}" 2>/dev/null || true)
if [ "$head" = "$tag_sha" ]; then
print_success "Already on the newest release, $tag"
elif git -c advice.detachedHead=false checkout --quiet --detach "${tag}^{commit}"; then
print_success "Updated to release $tag (stable channel)"
else
print_warning "Could not move to release $tag (local changes?); keeping the current version"
fi
return 0
fi
if git symbolic-ref --quiet HEAD >/dev/null 2>&1; then
# Newer than the newest release (or no release yet): follow the branch
# until a release includes this version, as updates do.
return 1
fi
print_success "This checkout is newer than the newest release${tag:+ ($tag)}; leaving it as it is"
return 0
}
# --- end release checkout helpers --------------------------------------------
# Main installation function
main() {
print_step "LED Matrix One-Shot Installation"
@@ -292,7 +404,10 @@ main() {
# Try to safely update current branch first (fast-forward only to avoid unintended merges)
PULL_SUCCESS=false
if git pull --ff-only origin "$CURRENT_BRANCH" >/dev/null 2>&1; then
# Stable: the newest release, if it contains this version.
if _lm_update_existing_checkout; then
PULL_SUCCESS=true
elif git pull --ff-only origin "$CURRENT_BRANCH" >/dev/null 2>&1; then
print_success "Repository updated successfully (branch: $CURRENT_BRANCH)"
PULL_SUCCESS=true
else
@@ -323,10 +438,12 @@ main() {
rm -rf "$REPO_DIR"
print_success "Cloning repository..."
retry git clone "$REPO_URL" "$REPO_DIR"
(cd "$REPO_DIR" && _lm_checkout_release_after_clone) || print_warning "Could not choose a release; installing the newest code from main"
fi
else
print_success "Cloning repository to $REPO_DIR..."
retry git clone "$REPO_URL" "$REPO_DIR"
(cd "$REPO_DIR" && _lm_checkout_release_after_clone) || print_warning "Could not choose a release; installing the newest code from main"
fi
# Verify repository is accessible
@@ -397,6 +514,7 @@ main() {
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 \
LEDMATRIX_APT_UPDATED="${LEDMATRIX_APT_UPDATED:-0}" \
LEDMATRIX_AUTO_UPDATE="${LEDMATRIX_AUTO_UPDATE:-}" \
LEDMATRIX_CHANNEL="${LEDMATRIX_CHANNEL:-}" \
bash ./first_time_install.sh -y </dev/null
fi
INSTALL_EXIT_CODE=$?
+25 -5
View File
@@ -15,7 +15,8 @@ The updater leaves data/auto_update_pending.json:
{"status": "pending", "old_head": ..., "new_head": ...,
"old_ref": "main" | "" (detached) | absent (older updaters),
"display_was_active": bool, "dependency_failures": [...], "created_at": ...}
"display_was_active": bool, "dependency_failures": [...],
"units_refreshed": bool (absent from older updaters), "created_at": ...}
This moves its status to "verifying" and then to one of "success",
"rolled_back" or "rollback_failed", with "reason" and "detail" saying why.
@@ -74,6 +75,10 @@ BASH_CANDIDATES = ('/usr/bin/bash', '/bin/bash')
#: ...and, like it, moves to the next one only when sudo refused the command
#: line (permission_utils.SUDO_REFUSAL_PHRASES), never after pip itself ran.
SUDO_REFUSAL_PHRASES = ('a password is required', 'is not allowed to run', 'no tty present')
#: The root-owned helper that installed the update's systemd units
#: (web_interface/unit_refresh.py); ``--restore`` puts the previous ones back.
REFRESH_UNITS_PATH = '/usr/local/sbin/ledmatrix-refresh-units'
UNIT_RESTORE_TIMEOUT_SECONDS = 90
#: The longest one health check can take: restart and wait, roll back
#: (diff, reset, reinstalls), restart and wait again. A wait's last poll can
@@ -81,7 +86,8 @@ SUDO_REFUSAL_PHRASES = ('a password is required', 'is not allowed to run', 'no t
_WAIT_WORST_SECONDS = (HEALTH_TIMEOUT_SECONDS + STABLE_SECONDS + WEB_CHECK_TIMEOUT_SECONDS
+ 2 * SYSTEMCTL_QUERY_TIMEOUT_SECONDS + POLL_SECONDS)
WORST_CASE_SECONDS = (2 * (2 * RESTART_TIMEOUT_SECONDS + _WAIT_WORST_SECONDS)
+ GIT_TIMEOUT_SECONDS + GIT_RESET_TIMEOUT_SECONDS + PIP_BUDGET_SECONDS)
+ GIT_TIMEOUT_SECONDS + GIT_RESET_TIMEOUT_SECONDS + UNIT_RESTORE_TIMEOUT_SECONDS
+ PIP_BUDGET_SECONDS)
#: What a command that could not run at all reports: its callers only read
#: these three fields, the same ones a completed subprocess has.
@@ -300,12 +306,26 @@ class Verifier:
if result.returncode != 0:
return False, (f'"git reset --hard {old}" failed: '
f'{(result.stderr or result.stdout or "").strip()}')
notes = []
# The update also installed its own systemd units: put the previous
# ones back before anything restarts onto the rolled-back code.
if pending.get('units_refreshed') and not self.restore_units():
notes.append('restoring the previous service settings failed; run '
'"sudo ./scripts/install/install_service.sh" in the LEDMatrix folder')
deadline = self.clock() + PIP_BUDGET_SECONDS
failed = [rel for rel in requirements if not self.install_requirements(rel, deadline)]
if failed:
return True, ('reinstalling the previous dependencies from ' + ', '.join(failed)
+ ' failed; run Install Base Requirements from the Tools tab')
return True, ''
notes.append('reinstalling the previous dependencies from ' + ', '.join(failed)
+ ' failed; run Install Base Requirements from the Tools tab')
return True, '; '.join(notes)
def restore_units(self):
"""Reinstall the systemd units the update replaced. True on success."""
result = self._run(['sudo', '-n', REFRESH_UNITS_PATH, '--restore'],
timeout=UNIT_RESTORE_TIMEOUT_SECONDS)
if result.returncode != 0:
self.log(f'restoring the previous systemd units failed: {(result.stderr or "").strip()}')
return result.returncode == 0
# -- the check itself -------------------------------------------------